Class: Jekyll::Crypto

Inherits:
Struct
  • Object
show all
Defined in:
lib/jekyll/crypto.rb

Overview

The Crypto class encrypts the contents of a given Jekyll::Document using a password defined in the metadata or globally on the Jekyll::Site configuration.

Constant Summary collapse

ITERATIONS =
20_000.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#postObject

Returns the value of attribute post

Returns:

  • (Object)

    the current value of post



9
10
11
# File 'lib/jekyll/crypto.rb', line 9

def post
  @post
end

#siteObject

Returns the value of attribute site

Returns:

  • (Object)

    the current value of site



9
10
11
# File 'lib/jekyll/crypto.rb', line 9

def site
  @site
end

Instance Method Details

#auth_dataString

Random authentication data

Returns:

  • (String)

    A random string



123
124
125
# File 'lib/jekyll/crypto.rb', line 123

def auth_data
  @auth_data ||= cipher.auth_data = OpenSSL::Random.random_bytes(16)
end

#auth_tagObject

Return the authentication tag, this ensures encrypted content hasn’t been tampered with.



73
74
75
# File 'lib/jekyll/crypto.rb', line 73

def auth_tag
  @auth_tag ||= cipher.auth_tag(16)
end

#cipherOpenSSL::Cipher::AES

Initializes AES-GCM-256 on encryption mode

Returns:

  • (OpenSSL::Cipher::AES)


15
16
17
# File 'lib/jekyll/crypto.rb', line 15

def cipher
  @cipher ||= OpenSSL::Cipher::AES.new(256, :GCM).encrypt
end

#ciphertextObject

Encrypt the rendered content



67
68
69
# File 'lib/jekyll/crypto.rb', line 67

def ciphertext
  @ciphertext ||= cipher.update(plain_content) + cipher.final
end

#derived_keyString

Derive a stronger key from the password using PBKDF2

Returns:

  • (String)


22
23
24
25
26
27
28
# File 'lib/jekyll/crypto.rb', line 22

def derived_key
  @derived_key ||= OpenSSL::KDF.pbkdf2_hmac(password.to_s,
                                            salt: salt,
                                            iterations: iterations,
                                            length: hash.digest_length,
                                            hash: hash)
end

#encrypt!String

Encrypts and replaces the rendered content of a Document.

Returns:

  • (String)


33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/jekyll/crypto.rb', line 33

def encrypt!
  cipher.key = derived_key

  # Make these available to any theme that understands Jekyll::Crypto
  post.data['iv'] = iv.bytes
  post.data['salt'] = salt.bytes
  post.data['iterations'] = iterations
  post.data['data'] = auth_data

  # The content is replaced by an HTML representation of the
  # encryption parameters so Jekyll::Crypto can transparently
  # encrypt any theme.
  post.content = to_html
end

#hashOpenSSL::Digest::SHA256

Hashing function for key derivation

Returns:

  • (OpenSSL::Digest::SHA256)

    hash



109
110
111
# File 'lib/jekyll/crypto.rb', line 109

def hash
  @hash ||= OpenSSL::Digest::SHA256.new
end

#iterationsInteger

The amount of iterations for key derivation

Returns:

  • (Integer)

    iterations



100
101
102
103
104
# File 'lib/jekyll/crypto.rb', line 100

def iterations
  @iterations ||= post.data['iterations'] ||
    site.config['iterations'] ||
    ITERATIONS
end

#ivString

The Initialization Vector for encryption

Returns:

  • (String)

    IV



116
117
118
# File 'lib/jekyll/crypto.rb', line 116

def iv
  @iv ||= cipher.random_iv
end

#passwordString

Obtains the password for this Document or the global password for the site. If any of them is missing an exception is thrown.

This is private information and is not made available as Document metadata.

Returns:

  • (String)

    password



91
92
93
94
95
# File 'lib/jekyll/crypto.rb', line 91

def password
  @password ||= post.data['password'] ||
    site.config['password'] ||
      raise(ArgumentError, 'Password missing, see jekyll-crypto documentation.')
end

#plain_contentObject



48
49
50
# File 'lib/jekyll/crypto.rb', line 48

def plain_content
  @plain_content ||= post.content.dup
end

#saltString

The salt is a random 16 bytes (128 bits) long String

Returns:

  • (String)

    salt



80
81
82
# File 'lib/jekyll/crypto.rb', line 80

def salt
  @salt ||= OpenSSL::Random.random_bytes(16)
end

#to_htmlString

Dumps the encrypted content as HTML

The ciphertext for JS is a concatenation of encrypted text and a 16 bytes (128 bits) authentication tag that can later be decrypted by [SubtleCrypto](developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/decrypt).

TODO: Move to template and use Liquid renderer

Returns:

  • (String)

    Encrypted content with parameters



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/jekyll/crypto.rb', line 137

def to_html
  @to_html ||= <<~HTML
    <div
      class="jekyll-crypto"
      data-salt="#{JSON.dump salt.bytes}"
      data-iv="#{JSON.dump iv.bytes}"
      data-iterations="#{iterations}"
      data-auth-data="#{JSON.dump auth_data.bytes}">

      <form>
        <input type="password" name="password" autocomplete="off" required />
        <input type="submit" />
      </form>

      <span class="ciphertext" style="word-wrap: break-word;display: none;">
        #{JSON.dump ciphertext.bytes.concat(auth_tag.bytes)}
      </span>
    </div>

    <script type="text/javascript">
      const jekyllCrypto = document.querySelectorAll('.jekyll-crypto');
      const textEncoder = new TextEncoder();
      const textDecoder = new TextDecoder();
      const parseBytes = (bytesString) => Uint8Array.from(JSON.parse(bytesString));

      jekyllCrypto.forEach(container => {
        container.querySelector('form').addEventListener('submit', event => {
          event.stopPropagation();
          event.preventDefault();

          const password = container.querySelector('input[name=password]').value;
          const salt = parseBytes(container.dataset.salt);
          const iv = parseBytes(container.dataset.iv);
          const iterations = parseInt(container.dataset.iterations);
          const authData = parseBytes(container.dataset.authData);
          const cipherText = parseBytes(container.querySelector('.ciphertext').innerText);

          window.crypto.subtle.importKey(
            "raw", 
            textEncoder.encode(password), 
            { name: "PBKDF2" }, 
            false, 
            [ "deriveBits", "deriveKey" ]
          ).then(keyMaterial => {
            window.crypto.subtle.deriveKey(
              { "name": "PBKDF2", salt: salt, "iterations": iterations, "hash": "SHA-256" },
              keyMaterial,
              { "name": "AES-GCM", "length": 256 },
              false,
              [ "encrypt", "decrypt" ]
            ).then(derivedKey => {
              window.crypto.subtle.decrypt(
                { name: "AES-GCM", iv: iv, additionalData: authData, tagLength: 128 },
                derivedKey,
                cipherText
              ).then(decrypted => container.innerHTML = textDecoder.decode(decrypted));
            });
          });
        });
      });
    </script>
  HTML
end

#valid?Boolean

Tries to decrypt to prove everything went well

Returns:

  • (Boolean)


53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/jekyll/crypto.rb', line 53

def valid?
  decipher = OpenSSL::Cipher::AES.new(256, :GCM).decrypt

  decipher.key = derived_key
  decipher.iv = iv
  decipher.auth_tag = auth_tag
  decipher.auth_data = auth_data

  # This will throw an OpenSSL::Cipher::CipherError exception is
  # something goes wrong in the implementation.
  decipher.update(ciphertext) + decipher.final
end