Share
Embed
End-to-End Encryption Saves Lives. You Can Start Saving Lives With Ruby, Too (RubyConf Taiwan 2025 ver.)
sylph01
August 09, 2025
Transcript
106 slides
-
End-to-End Encryption Saves Lives. You Can Start Saving Lives With
Ruby, Too Ryo Kajiwara/ 梶原 龍 (sylph01) 2025/8/9 @ RubyConf Taiwan + COSCUP 2025 1 -
Slides are available at: http:/ /bit.ly/4mx0cEq (QR code uses the
full Speaker Deck URL) 2 -
I flew from 松山 (MYJ) to 松山 (TSA) again It
says Ehime and Matsuyama but this is the SweetMe Hotspring Resort ( 水美温泉會館 ) in Xinbeitou 4 -
I do stuff Play rhythm games (especially DanceDanceRevolution) Play the
bassoon/contrabassoon and DJ gear Ride a lot of trains (Rails!) (travelled on 99% of JR) Build keyboards Organized RubyKaigi 2025 as a Local Organizer if anything catches your interest let's talk! 5 -
And I do stuff that is more relevant to this
talk: Freelance web developer focused on Digital Identity and Security Worked/ing on writing/editing and implementing standards HTTPS in Local Network CG / Web of Things WG @ W3C, OAuth / Messaging Layer Security WG @ IETF Worked as an Officer of Internet Society Japan Chapter (2020-23) 6 -
The Usual Disclaimer Cryptographic API can be very easy to
misuse Operational Security is also very difficult I've done my research, but I don't consider myself a cryptography expert a.k.a. "I am not djb" If you're not sure, please have your system audited by a security expert before going to production also I don't have a PhD/Master's degree in this field so yeah... 7 -
Notes on Production Readiness The MLS implementation is still in
progress Passes most test vectors Lacks validation on error cases Client implementation is underway Hopefully release 1.0 by the next IETF (2025/11)...? I was aiming for 1.0 before IETF 126 (2025/7) but yeah... 8 -
I happen to be known as the SMTP をやめろ -guy
SMTP をやめろ = lit. "stop using SMTP"; more like "SMTP is dead" 11 -
SMTP mail lacks two things: 1) Strong identity 2) End-to-end
Encryption MLS also helps with the identity part (with the full architecture), but that is service dependent 12 -
End-to-End Encryption = The people who run the service cannot
read your messages LINE, Facebook Messenger, Signal, WhatsApp, ... They each have their own version of E2EE Some apps' E2EE are opt-in 13 -
E2EE is more important than ever Increasing tension in international
affairs Powerful people wanting to control the Internet even in democratic countries! Provides a safe method to communicate under oppression / in war zones 15 -
Hey, but doesn't E2EE help criminals? E2EE is being targeted
by authorities even in democratic countries! Even if you ban E2EE, criminals will use it anyways Banning E2EE disproportionately harms vulnerable people 16 -
HTTPS(TLS) also helps criminals, but are you going to ban
HTTPS(TLS)? 18 -
Messaging Layer Security More interoperable version of doing E2EE MLS
provides key exchange Protocol (RFC 9420), Architecture (RFC 9750) Today's topic is the implementation of the Protocol Now we (kinda) have RFC 9420 in Ruby! for MLS Architecture getting an RFC number 20 -
mls gem already exists (for a defunct website...) It exists
since 2012, so it predates the 00 Internet-Draft of Messaging Layer Security (2018/2) 22 -
Melos https:/ /github.com/sylph01/melos Taken from a novel that is in
most Japanese middle school/high school textbooks 23 -
Basics: Symmetric vs Asymmetric Cryptography Symmetric crypto: one key used
for both encryption and decryption Asymmetric crypto: Also known as Public Key Cryptography Alice keeps a pair of keys, public and private Bob can encrypt a plaintext using Alice's Public Key Then Alice decrypts the ciphertext with her Private Key 26 -
Building blocks: HPKE "Encrypt with Public Key, Decrypt with Private
Key" , formalized A combination of the following: Key Encapsulation Mechanism (KEM) ( ≒ asymmetric crypto) Key Derivation Function (KDF) ( ≒ hash) Authenticated Encryption with Associated Data (AEAD) (= symmetric crypto) Available in Ruby: hpke gem Seen this? I've talked about this at RubyConf Taiwan 2023 27 -
Security Characteristics Forward Secrecy: messages sent at a certain point
in time are secure in the face of later compromise of a group member secure against "harvest now, decrypt later" attack RFC 9420, Section 16.6 28 -
Security Characteristics Post-Compromise Security: messages are secure even if a
group member was compromised at some point in the past Each member updates their key so that group secrets are not always encrypted with private keys that have been compromised RFC 9420, Section 16.6 29 -
2 person is easy # both party starts with chain
key 0 chain_key[0] = "some common secret" # when sending a message... message_key[n] = hmac_sha256(chain_key[n], 0x02) # encrypt the message #(n) using message_key[n] chain_key[n+1] = hmac_sha256(chain_key[n], 0x01) Symmetric Key Ratchet part of the Double Ratchet algorithm 31 -
3+ person is difficult Extend 2-person method? Number of edges
in an n-node complete graph is O(n^2) 32 -
Trees with arrays Complete balanced trees can be described with
a flat array For example, the tree on the right will be written as: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, nil, 11, nil, nil, nil] 34 -
Trees with arrays Leaf nodes are always even indexed Parent
nodes are always odd indexed We define operations such as left , right , parent , sibling , ... based on the array-based tree's characteristics 35 -
Trees with arrays The algorithms are described in Appendix C
of RFC 9420 Also this comes with a serialization/deserialization format! 36 -
Where's the Tree? Users in a group will be assigned
a leaf in the ratchet_tree and the secret_tree with the same index. 38 -
Basically You want to agree with other users in the
group upon a common secret: epoch_secret[n], where n is the epoch number 39 -
Secret Tree If you have a common secret, the group
can derive the same (symmetric) keys for encrypting messages 40 -
Secret Tree def self.populate_tree_impl(suite, tree, index, secret) tree.array[index] = {
... } unless Melos::Tree.leaf?(index) left_secret = Melos::Crypto.expand_with_label(suite, secret, "tree", "left", suite.kdf.n_h) right_secret = Melos::Crypto.expand_with_label(suite, secret, "tree", "right", suite.kdf.n_h) populate_tree_impl(suite, tree, Melos::Tree.left(index), left_secret) populate_tree_impl(suite, tree, Melos::Tree.right(index), right_secret) end end From the base encryption secret, we recursively populate the tree down to its leaves file lib/melos/secret_tree.rb 41 -
Secret Tree The actual ratcheting part: def self.ratchet_application(suite, tree, leaf_index)
node_index = leaf_index * 2 generation = tree.array[node_index]['next_application_ratchet_secret_generation'] application_ratchet_secret = tree.array[node_index]['application_ratchet_secret'] application_nonce = Melos::Crypto.derive_tree_secret(suite, application_ratchet_secret, "nonce", generation, suite.hpke.n_n) application_key = Melos::Crypto.derive_tree_secret(suite, application_ratchet_secret, "key", generation, suite.hpke.n_k) next_application_ratchet_secret = Melos::Crypto.derive_tree_secret(suite, application_ratchet_secret, "secret", generation, suite.kdf.n_h) tree.array[node_index]['next_application_ratchet_secret_generation'] = generation + 1 tree.array[node_index]['application_ratchet_secret'] = next_application_ratchet_secret tree.array[node_index]['application_nonce'] = application_nonce tree.array[node_index]['application_key'] = application_key end file lib/melos/secret_tree.rb 42 -
Secret Tree It essentially boils down to this: key =
Crypto.derive_tree_secret(ratchet_secret[n], "key", n) nonce = Crypto.derive_tree_secret(ratchet_secret[n], "nonce", n) ratchet_secret[n + 1] = Crypto.derive_tree_secret(ratchet_secret[n], "secret", n) This gives you the key and nonce to encrypt the actual messages. Compare with this (two-party hash ratcheting): chain_key[0] = "some common secret" message_key[n] = hmac_sha256(chain_key[n], 0x02) chain_key[n+1] = hmac_sha256(chain_key[n], 0x01) 43 -
Key Schedule With the last epoch_secret and a common commit_secret
, the group can agree upon the next epoch_secret see lib/melos/key_schedule.rb and test/test_key_schedule.rb 44 -
the hardest part: TreeKEM How do we agree upon the
same commit_secret ? 45 -
TreeKEM Users are assigned a leaf node Users have their
leaf key pair 46 -
TreeKEM When a user wants to update the group secret,
user creates an UpdatePath 47 -
TreeKEM User generates a random path_secret at their leaf The
parent's path_secret is calculated using the child's path_secret Calculate that up to the root The commit_secret is calculated from the root's path_secret 48 -
TreeKEM path_secret[0] = (random) path_secret[1] = hash(path_secret[0]) path_secret[3] = hash(path_secret[1])
commit_secret = hash(path_secret[7]) where 7 is the root 49 -
TreeKEM When 0 creates its UpdatePath (yellow) We find the
copath nodes along the UpdatePath (green) If all ParentNode s in the tree are populated, we encrypt the path secret to the key of its copath node Public keys for parent nodes are derived from path_secret s 50 -
TreeKEM We can see that's possible in a 2-leaf tree
0 creates an UpdatePath, node 1 has a path_secret the path secret is encrypted to 2 's public key node 2 knows the private key, so 2 can decrypt 51 -
TreeKEM What happens if there are blanks in the tree?
We calculate the resolution of the copath node to figure out which node's keys are available. Then, we encrypt the path secret of the UpdatePath node to each key 52 -
TreeKEM The resolution of the node indicated left is [3,
2] The indicated node has unmerged leaves so its resolution is itself + list of unmerged leaves 53 -
TreeKEM The resolution of the node indicated left is [3,
2, 9, 14] The resolution is used to figure out the set of keys that are necessary to encrypt to every node under that node 54 -
TreeKEM So actually what you do is: Calculate path_secret s
for each node on UpdatePath then encrypt the path_secret s to each key on the resolution of the copath node 55 -
TreeKEM Encrypting the commit_secret itself and sending it to everyone
works but it's O(n) (n = # of members) Encrypting the commit_secret along the copath reduces the number of encryptions needed down to O(log n) 56 -
Summary Secret Tree gives you the per- message sender keys
based on epoch_secret Key Schedule gives you the next epoch_secret based on current epoch_secret and commit_secret TreeKEM gives you the commit_secret 57 -
Sending Application Messages Application messages (actual messages) are sent in
PrivateMessage s framed_content = Melos::Struct::FramedContent.create(...) authenticated_content = Melos::Struct::AuthenticatedContent.create(...) authenticated_content.sign(...) app_message = Melos::Struct::PrivateMessage.protect( authenticated_content, secret_tree, ... ) 58 -
Evolution of a Group A group is updated through messages
called proposals and commits Proposals Add and remove users Notifies an update of user's leaf key Injects Pre-Shared Keys Commits will fix those information and advances the group's epoch UpdatePath s are conveyed in the Commit 59 -
Evolution of a Group A Group is created by adding
a user's LeafNode into a 0-node group Starts with a random epoch_secret def init_group(node) # single node, a leaf node containing an HPKE PK and credential for the creator @ratchet_tree = [node] @leaf_index = 0 @tree_hash = Melos::Struct::RatchetTree.root_tree_hash(@cipher_suite, @ratchet_tree) @confirmed_transcript_hash = '' @epoch_secret = SecureRandom.random_bytes(@cipher_suite.kdf.n_h) ... @group_initialized = true end lib/group.rb from a work-in-progress version 60 -
Evolution of a Group Users who want to join the
group publishes their identity (including its public key) to a Directory using a KeyPackage They will receive a Welcome message that has the group state inside it Figure 2 in RFC 9420 Section 3.2. The Delivery Service is out of scope for this implementation 61 -
Evolution of a Group User A creates an initial group
To add User B , A sends an Add proposal adding B , then Commit s A sends a Welcome message to introduce B into the group Same for adding C Figure 3 in RFC 9420 Section 3.2. 62 -
Evolution of a Group When Add ing: Add the leaf_node
from the KeyPackage in the proposal to the leftmost empty node Then add the leaf index to intermediate nodes' unmerged list 63 -
Evolution of a Group User B sends an Update proposal
notifying the group of its leaf key update Then User A Commit s that update to include it in the epoch advancement Figure 4 in RFC 9420 Section 3.2. 64 -
Evolution of a Group When Update ing: Replace the leaf
node of the sender with the leaf_node inside the Update proposal Then blank all nodes on its direct path up to the root Updating the keys regularly gives the Group Post-Compromise Security 65 -
Evolution of a Group When removing a user: Z sends
a Remove proposal and a Commit B cannot decrypt the path_secret included in the Commit 's UpdatePath , so B will not know the next epoch_secret 66 -
Evolution of a Group When Remove ing: Blank the specified
node in the proposal Then blank all nodes on its direct path up to the root If the right half of the tree is fully empty, the tree is shrunk 3 becomes the new root 67 -
Evolution of a Group When are intermediate parent nodes filled?
During the processing of a Commit When processing a Commit , the UpdatePath inside the Commit is merged into the ratchet tree ParentNode s are created based on the UpdatePath content 68 -
Stuff we are omitting here Injection of Pre-Shared Keys Transcript
Hashes Hashes that summarize the proposals/commits taken place in the last epoch Signing/Verification of messages There are public messages and private messages 69 -
Current implementation state Passes most test vectors Updates from RubyKaigi
Parsing messages now use a StringIO instead of two String s Moved group state into a Group object Needs a user-friendly client API Needs interoperability testing 72 -
Work-in-progress Client Interface cipher_suite_id = 1 client = Melos::Client.new(cipher_suite_id) #
generate key pairs key_package = Melos::KeyPackage.new(client) # create KP to publish to DS leaf_node = key_package.create_leaf_node('credential_name') node = Melos::Struct::Node.new_leaf_node(leaf_node) group = Melos::Group.new(client, 'group_id') group.init_group(node) # create a 1-node group 73 -
Work-in-progress Client Interface client2 = Melos::Client.new(cipher_suite_id) key_package2 = Melos::KeyPackage.new(client2) leaf_node2
= key_package2.create_leaf_node('credential_name2') key_package_message2 = key_package2.create_message(leaf_node2) # User 1 creates add proposal prop = group.create_add_proposal( key_package_message2, client.signature_private_key) # something like this...? commit = create_commit(prop, ...) group.apply_commit(commit) group2.apply_welcome(welcome) 74 -
:contribute-chance: "Refactoring" Wrapping raw values into classes Group is being
worked on RatchetTree is now represented as a raw array Interoperability testing Learn other implementations and create test scenarios There is an automated interop suite that uses gRPC :contribute-chance: is a Slack emoji in #ruby-jp used for indicating good first issues 75 -
HPKE using OpenSSL's API HPKE is nice, and OpenSSL itself
has APIs that do this Talked about it at RubyConf Taiwan 2023 Haven't worked on it much since 77 -
HPKE using OpenSSL's API But is it a good idea?
Protocols need the whole cipher suite Not only encap/decap and open/seal Also want access to constants such as hash/key length OpenSSL's HPKE context remembers which algorithm to use, but to use them separately you have to call them separately 78 -
Example def self.sender_data_key(suite, sender_data_secret, ciphertext) ciphertext_sample = ciphertext[0..(suite.kdf.n_h - 1)]
expand_with_label(suite, sender_data_secret, "key", ciphertext_sample, suite.hpke.n_k) end def self.sender_data_nonce(suite, sender_data_secret, ciphertext) ciphertext_sample = ciphertext[0..(suite.kdf.n_h - 1)] expand_with_label(suite, sender_data_secret, "nonce", ciphertext_sample, suite.hpke.n_n) end n_h is hash length, n_k and n_n is the key and nonce length for the symmetric crypto 79 -
EC vs X25519/X448 APIs in OpenSSL Checking correspondence of key
pairs def self.signature_key_pair_corresponds?(suite, private_key, public_key) private_pkey = suite.pkey.deserialize_private_signature_key(private_key) public_pkey = suite.pkey.deserialize_public_signature_key(public_key) if suite.pkey.equal?(Melos::Crypto::CipherSuite::X25519) || suite.pkey.equal?(Melos::Crypto::CipherSuite::X448) # is an Edwards curve; check equality of the raw public key private_pkey.raw_public_key == public_pkey.raw_public_key else # is an EC; check equality of the public key Point private_pkey.public_key == public_pkey.public_key end end 80 -
EC vs X25519/X448 APIs in OpenSSL Getting EC public/private keys
in UncompressedPointRepresentation form def self.derive_key_pair(suite, secret) pkey = suite.hpke.kem.derive_key_pair(secret) if suite.pkey.equal?(Melos::Crypto::CipherSuite::X25519) || suite.pkey.equal?(Melos::Crypto::CipherSuite::X448) # is an Edwards curve [pkey.raw_private_key, pkey.raw_public_key] else # is an EC [pkey.private_key.to_s(2), pkey.public_key.to_bn.to_s(2)] end end 81 -
Additional features in OpenSSL OpenSSL 3.5.0 ships with post-quantum cryptography
ML-KEM, ML-DSA, SLH-DSA There is an Internet-Draft that adds ML-KEM in MLS 82 -
Grand Unifying Cryptography API Different platforms depend on different cryptographic
libraries Desktop: OpenSSL Compatible (to an extent) with LibreSSL, BoringSSL, and the like Browser: Web Crypto API Embedded: Mbed TLS, wolfSSL, ... We have Mbed TLS support in PicoRuby Can we have a unified API wrapper for these libraries? Actually ruby-wasm has OpenSSL compiled in wasm, but doing it through browser APIs is faster 85 -
Grand Unifying Cryptography API Very quick example of calling Web
Crypto API's random number generator require "js" array = JS::eval('return new Uint8Array(16)') JS.global[:window][:crypto].getRandomValues(array) p array 86 -
Why do you need End-to-End Encryption in Ruby? Because... This
is a DanceDanceRevolution reference. see: MAX.(period), Over the "Period" 89 -
Ruby needs them to stay relevant or else people would
just use Python, Go, Rust, whatever the cool kids use these days Actually, Python doesn't have an MLS implementation yet, so it's a win for Ruby 90 -
Ruby needs Freedom on the Internet and the Free Internet
needs Ruby free as in freedom 91 -
Takeaways We have interoperable ways of doing E2EE We (kinda)
have this in Ruby too Please use secure messaging in your daily life 92 -
Shoutouts The Messaging Layer Security Working Group @ IETF Protocol
implementers in Ruby (the list is growing!) RubyConf Taiwan 2025 Organizers 93 -
Questions? / Comments? Twitter: @s01 or Fediverse: @[email protected] also find
me in the venue / at drinkups! I will be at the Official Code Party 94 -
It's not just a pack and unpack MLS has variable
length vectors and optional values in their structs struct { opaque group_id<V>; uint64 epoch; ContentType content_type; opaque authenticated_data<V>; opaque encrypted_sender_data<V>; opaque ciphertext<V>; } PrivateMessage; 97 -
Variable length vectors struct { uint32 fixed<0..255>; opaque variable<V>; }
StructWithVectors; Based on variable-length integer encoding in RFC 9000, Section 16 2-bit prefix if 00 , length is encoded with 6 bits if 01 , length is encoded with 14 bits if 11 , length is encoded with 30 bits 98 -
Optional values struct { uint8 present; select (present) { case
0: struct{}; case 1: T value; }; } optional<T>; 99 -
Define structs like this class Melos::Struct::PrivateMessage < Melos::Struct::Base attr_reader :group_id,
:epoch, :content_type, :authenticated_data, :encrypted_sender_data, :ciphertext STRUCT = [ [:group_id, :vec], [:epoch, :uint64], [:content_type, :uint8], [:authenticated_data, :vec], [:encrypted_sender_data, :vec], [:ciphertext, :vec] ] 100 -
Melos::Struct::Base #initialize , .new_and_rest : deserializer #raw : serializer These
operate based on the STRUCT constant of the class lib/melos/struct/base.rb 101 -
Deserialize each element like this def deserialize_elem(buf, type, type_param) case
type when :uint8 value = buf.byteslice(0, 1).unpack1('C') buf = buf.byteslice(1..) when :uint16 value = buf.byteslice(0, 2).unpack1('S>') buf = buf.byteslice(2..) (snip) when :vec value, buf = Melos::Vec.parse_vec(buf) (snip) lib/melos/struct/base.rb; I'm likely rewriting this with a StringIO instead of using strings as buffers 102 -
Sometimes a class is nested class Melos::Struct::FramedContent < Melos::Struct::Base (snip)
STRUCT = [ [:group_id, :vec], [:epoch, :uint64], [:sender, :class, Melos::Struct::Sender], [:authenticated_data, :vec], [:content_type, :uint8], (snip) ] lib/melos/struct/structs.rb 103 -
So we recursively call new when :class value, buf =
type_param.send(:new_and_rest, buf) when :classes # prefix, length = buf.get_prefix_and_length # puts "#{prefix}, #{length}" vec, buf = Melos::Vec.parse_vec(buf) value = [] while (vec.bytesize > 0) current_instance, vec = type_param.send(:new_and_rest, vec) value << current_instance end lib/melos/struct/structs.rb 104 -
We sometimes have values that depend on other values class
Melos::Struct::Sender < Melos::Struct::Base attr_reader :sender_type, :leaf_index, :sender_index STRUCT = [ [:sender_type, :uint8], [:leaf_index, :select, ->(ctx){ctx[:sender_type] == 0x01}, :uint32], [:sender_index, :select, ->(ctx){ctx[:sender_type] == 0x02}, :uint32], ] lib/melos/struct/structs.rb 105 -
We call the Proc with the current context to handle
that def deserialize_select_elem_with_context( buf, context, predicate, type, type_param) if predicate.(context) deserialize_elem(buf, type, type_param) else [nil, buf] end end lib/melos/struct/base.rb 106