This is an implementation of the PASETO token protocol, written in Ruby, which supports versions v3 and v4. This library passes all official test vectors for supported versions and purposes.
Additionally, the library uses Sorbet to enforce types at runtime.
Installing
RbNaCl and libsodium
Optional for v4.local tokens support: Handling v4.local tokens requires RbNaCl with libsodium 1.0.0 or newer.
You can find instructions for obtaining libsodium at libsodium.org.
If you do not intend to create or parse v4.local tokens, feel free to skip this dependency.
Using Bundler
Add the following to your Gemfile:
gem 'ruby-paseto' # and optionally: gem 'rbnacl', '~> 7.1.1'
Then, run bundle install and require 'paseto'.
Supported PASETO versions
paseto supports these PASETO versions and purposes:
| purpose | v4 | v3 |
|---|---|---|
local |
✅ | ✅ |
public |
✅ | ✅ |
Support for PASERK types
| v4 | v3 | |
|---|---|---|
lid |
✅ | ✅ |
sid |
✅ | ✅ |
pid |
✅ | ✅ |
local |
✅ | ✅ |
secret |
✅ | ✅ |
public |
✅ | ✅ |
seal |
✅ | ✅ |
local-wrap |
✅ | ✅ |
secret-wrap |
✅ | ✅ |
local-pw |
✅ | ✅ |
secret-pw |
✅ | ✅ |
Implementation Guideline compliance
- require payload to be UTF-8 encoded
- enforce JSON encoding of payload
- require topmost object to be an object, map, or associative array
- protect against loading off-curve public keys
- support for Validators
- protect against arbitrary/invalid data in Registered Claims
- Key-ID support
Basic Usage
claims = { "foo" => "bar", "baz" => 1, "time" => Time.now } symmetric = Paseto::V4::Local.generate asymmetric = Paseto::V4::Public.generate footer = {'kid' => asymmetric.pid} signed_token = asymmetric.encode(claims, footer: footer) # => "v4.public.eyJ..." result = asymmetric.decode(signed_token) # => <Paseto::Result claims={"exp"=>"2022-12-...", "iat"=>"2022-12-...", # "nbf"=>"2022-12-...", "foo"=>"bar", "baz"=>1, "time"=>"2022-12-..."}, # footer={"kid"=>"k4.pid.Mu7prut6-zPCIkJ..."}> result.claims == claims # => false result.claims['foo'] == 'bar' # => true # To opt out of default claims use `encode!` # When using encode!, footer must be a string if provided encrypted_token = symmetric.encode!(claims, footer: JSON.dump(footer)) # => "v4.local.aM0k..." result = symmetric.decode(encrypted_token) # => <Paseto::Result claims={"foo"=>"bar", "baz"=>1, "time"=>"2022-12-..."}, # footer={"kid"=>"k4.pid.h8fe-zLYEss_..."}> result.claims == claims # => true
The encode and decode methods always comply with the PASETO Payload Processing guidelines.
paseto uses multi_json to handle JSON serialization and deserialization, so you may configure your adapter as you please.
Symbol keys are always converted to strings. Serializer configurations or options which cause keys to decode as non-string values, such as symbolize_keys, or cause registered claim values to decode as non-strings, are unsupported.
encrypter.decode(encrypted_token) # => {"foo" => "bar", "baz" => {"^t"=>"2022-12-08T20:26:55.213+00:00"}} # You may pass JSON adapter options to encode and decode encrypter.decode(encrypted_token, mode: :object) # => {"foo" => "bar", "baz" => 2022-12-08 20:26:55 15023727/70368744177664 +0000} # Or setting default options with Oj Oj.default_options = {mode: :object} encrypter.decode(encrypted_token) # => {"foo" => "bar", "baz" => 2022-12-08 20:26:55 15023727/70368744177664 +0000}
See Registered Claims for more information on registered claim validation.
Encryption and Decryption
These operations are performed with Local key instances, which are always initialized with a 256-bit raw key.
crypt = Paseto::V4::Local.generate token = crypt.encode({'foo' => 'bar'}) # => "v4.local.DhLZ..." (local PASETO) token = crypt.encode({'foo' => 'bar'}, footer: '', implicit_assertion: '' ) # same as above result = crypt.decode(token) # => <Paseto::Result ...> result.claims # => {'foo' => 'bar'} # exporting key material crypt.key # => The IKM used for this key as raw bytes crypt.lid # => "k4.lid.uGj..." (PASERK type lid) crypt.id # => same as above crypt.paserk # => "k4.local.tnVpN4t..." (PASERK Type local)
Message Signing and Verification
These options are performed with Public key instances, which may be initialized with a DER- or PEM-encoded public or private key.
signer = Paseto::V4::Public.generate token = signer.encode({'foo' => 'bar'}) # => "v4.public.sKd3..." (public PASETO) token = signer.encode( {'foo' => 'bar'}, footer: '', implicit_assertion: '' ) # same as above result = signer.decode(token) # => <Paseto::Result ...> result.claims # => {'foo' => 'bar'} # or initialize only for verification verifier = Paseto::V4::Public.new(signer.public_to_pem) verifier.decode(token) # => <Paseto::Result ...> verifier.encode({'foo' => 'bar'}) # => ArgumentError # exporting key material signer.public_to_pem # => PEM signer.private_to_pem # => PEM if private material available, otherwise ArgumentError signer.sid # => "k4.sid.y5x..." (PASERK Type sid) signer.id # => same as above signer.pid # => "k4.pid.5g4..." (PASERK Type pid) verifier.pid # => same as above! verifier.id # => same as above
PASETO/PASERK v4, Sodium Modern
Description and Specification.
The rbnacl gem is required for access to any v4 algorithm.
Encryption and Decryption
crypt = Paseto::V4::Local.generate # or initialize with a known 256-bit string ikm = SecureRandom.bytes(32) crypt = Paseto::V4::Local.new(ikm: ikm)
Message Signing and Verification
signer = Paseto::V4::Public.generate # => Paseto::V4::Public # or initialize from a DER- or PEM-encoded Ed25519 key pem = File.read('my.pem') signer = Paseto::V4::Public.new(pem)
PASETO v3, NIST Modern
Description and Specification.
The openssl gem is required for access to any v3 algorithm.
Encryption and Decryption
crypt = Paseto::V3::Local.generate # or initialize with a known key ikm = SecureRandom.bytes(32) crypt = Paseto::V3::Local.new(ikm: ikm)
Message Signing and Verification
signer = Paseto::V3::Public.generate # => Paseto::V3::Public # or initialize from a PEM- or DER-encoded secp384r1 key signer = Paseto::V3::Public.new(signer.private_to_pem)
PASERK
Accessing the PASETO Footer Before Decoding
Paseto::Token is used for decoding and accessing the different components of a PASETO, and can be used for retrieving a kid or wpk footer claim before decoding the full token.
Token objects respond to #inspect and to_s with the PASETO token that they represent.
signer = Paseto::V4::Public.generate claims = { "foo" => "bar", "baz" => 1, "time" => Time.now } footer = '"foo"' signed_token = signer.encode(claims, footer: footer) t = Paseto::Token.parse(signed_token) # => the signed_token value t.class # => Paseto::Token t.footer # => "\"foo\"" # You must decode! a Token with the correct Key before accessing the payload t.payload # => Paseto::ParseError t.decode!(signer) # => {"exp"=> ...} t.payload # => {"exp"=> ...} # The footer is automatically deserialized to a Hash when appropriate signed_token = signer.encode(claims, footer: {'kid' => signer.pid}) t = Paseto::Token.parse(signed_token) # => "v4.public.fOQ2s..." t.footer # => {"kid"=>"k4.pid.h5u7u..."} t.footer['kid'] == signer.pid # => true key = MyKeyStore.fetch(footer['kid']) t.decode!(key) # => {"exp"=> ...}
ID, PASERK IDs
See Basic Usage.
Wrap, Symmetric-Key Wrapping
Encrypt a symmetric key with another symmetric key using the PIE wrapping algorithm. Other wrapping algorithms are not supported.
wrapping_key = Paseto::V4::Local.generate local_secret = Paseto::V4::Local.generate # Encrypt local_secret with wrapping_key using the PIE algorithm paserk = wrapping_key.wrap(local_secret) # => "k4.local-wrap.pie.8qQp..." # Decrypt a secret-wrap or local-wrap PASERK unwrapped = wrapping_key.unwrap(paserk) # => #<Paseto::V4::Local ...> unwrapped == local_secret # => true
PKE, Public-Key Encryption
Encrypt a symmetric key with a public key, and decrypt with a private key.
wrapping_key = Paseto::V4::Public.generate local_secret = Paseto::V4::Local.generate # Seal a Local key with a Public key paserk = wrapping_key.seal(local_secret) # => "k4.seal.I1Hn..." # Unseal a sealed PASERK unwrapped = wrapping_key.unseal(paserk) # => #<Paseto::V4::Local ...> unwrapped == local_secret # => true
PBKD, Password-Based Key Wrapping
Note: This operation is slow.
This operation encrypts any key with a password-derived encryption key.
Different parameters for the derivation may be provided depending on the version of the key being encrypted.
secret_key = Paseto::V4::Local.generate # May be either Local or Public # Equivelant to :sensitive for both values options = { opslimit: 4, memlimit: 1_073_741_824 } paserk = secret_key.pbkd(password: 'hunter2', options: options) # => k4.local-pw.hSmzsCmWhiT... key = Paseto::Paserk.from_paserk(paserk: paserk, password: 'hunter2') # => #<Paseto::V4::Local ...> key == secret_key # => true
Version 4, Argon2id Parameters
| param | allowed values | default |
|---|---|---|
opslimit |
Symbol | Integer | :interactive |
memlimit |
Symbol | Integer | :interactive |
Allowed symbol values are :interactive, :moderate and :sensitive as described in RbNaCl documentation.
You most likely do not want to use the :interactive default in production. See libsodium's documentation for parameter guidance.
Version 3, PBKDF2 Parameters
| param | allowed values | default |
|---|---|---|
iterations |
Integer | 100_000 |
As with the Version 4 parameters above, you most likely do not want to use the default value and should pick an iterations value with time objectives similar to the argon2 guidance.
Registered Claims
PASETO reserves some claim names for particular use in the protocol, and this gem supports verification of all reserved claims.
In the default configuration, the following claims are verified when present: exp, nbf and iat
Verification behavior can be controlled by passing a kwarg for the setting to decode calls, or through library-level configuration.
Default Configuration
See the appropriate section below for more information on configuring each verify_foo.
Paseto.configure do |config| # Controls the behavior of footer deserialization in Result objects. # Paseto::Serializer::Raw is the other built-in option, # but is incompatible with registered claim validation. config.footer_serializer = Paseto::Serializer::OptionalJson config.verify_exp = true config.verify_nbf = true config.verify_iat = true config.verify_iss = false config.verify_aud = false config.verify_sub = false config.verify_jti = false end
Audience Claim
| claim type | config type | default |
|---|---|---|
| String | False | String | Array[String] | false |
crypt = Paseto::V4::Local.generate hash = { aud: 'example.com', data: 'data' } payload = crypt.encode(hash) audience = 'some.example.com' crypt.decode(payload, verify_aud: audience) # => Paseto::InvalidAudience audience = ['some.example.com', 'another.example.com', 'example.com'] crypt.decode(payload, verify_aud: audience) # => { 'aud' => ... }
Expiration Claim
| claim type | config type | default |
|---|---|---|
| DateTime | Boolean | true |
crypt = Paseto::V4::Local.generate # Override the default value by specifying a new value in your hash hash = { exp: (Time.now - 1).iso8601, data: 'data' } payload = crypt.encode(hash) crypt.decode(payload) # => Paseto::ExpiredToken crypt.decode(payload, verify_exp: false) # { 'exp' => ... }
Issued At Claim
| claim type | config type | default |
|---|---|---|
| DateTime | Boolean | true |
crypt = Paseto::V4::Local.generate # Override the default value by specifying a new value in your hash hash = { iat: (Time.now + 5).iso8601, data: 'data' } payload = crypt.encode(payload: hash) crypt.decode(payload) # => Paseto::ImmatureToken crypt.decode(payload, verify_iat: false) # { 'iat' => ... }
Issuer Claim
| claim type | config type | default |
|---|---|---|
| String | Boolean | String | Regexp | Proc | false |
crypt = Paseto::V4::Local.generate hash = { iss: 'example.com', data: 'data' } payload = crypt.encode(payload: hash) crypt.decode(payload, verify_iss: 'not.example.com') # => Paseto::InvalidIssuer
You may also pass a Regexp or Proc with arity 1, and verification will succeed if the regexp matches or the proc returns truthy.
issuer = /\Aexample\.com\z/ crypt.decode(payload, verify_issuer: issuer) # { 'iss' => ... } issuer_proc = ->(iss) { iss.end_with?('example.com') } crypt.decode(payload, verify_issuer: issuer_proc) # { 'iss' => ... } # or verify only presence crypt.decode(payload, verify_issuer: true) # { 'iss' => ... }
Not Before Claim
| claim type | config type | default |
|---|---|---|
| DateTime | Boolean | true |
crypt = Paseto::V4::Local.generate # Override the default value by specifying a new value in your hash hash = { nbf: (Time.now + 5).iso8601, data: 'data' } payload = crypt.encode(payload: hash) crypt.decode(payload) # => Paseto::InactiveToken crypt.decode(payload, verify_nbf: false) # => { 'nbf' => ... }
Subject Claim
| claim type | config type | default |
|---|---|---|
| String | False | String | false |
crypt = Paseto::V4::Local.generate hash = { sub: 'example.com', data: 'data' } payload = crypt.encode(payload: hash) crypt.decode(payload, verify_sub: 'example.com') # { 'sub' => ... } crypt.decode(payload, verify_sub: 'example.org') # => Paseto::InvalidSubject
Token Identifier Claim
| claim type | config type | default |
|---|---|---|
| String | Boolean | String | Proc | false |
crypt = Paseto::V4::Local.generate hash = { data: 'data' } payload = crypt.encode(hash) # Require presence crypt.decode(payload, verify_jti: true) # => Paseto::InvalidTokenIdentifier # Require exact value hash[:jti] = 'foo' payload = crypt.encode(payload: hash) crypt.decode(payload, verify_jti: 'foo') # { 'data' => ... } crypt.decode(payload, verify_jti: 'bar') # Paseto::InvalidTokenIdentifier # Or something more complex jti_proc = ->(jti) { jti == 'bar'} crypt.decode(payload, verify_jti: jti_proc) # Paseto::InvalidTokenIdentifier
Development
After checking out the repo, run bin/setup to install dependencies.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
You can also run bin/console for an interactive prompt that will allow you to experiment.
Type Checking
paseto uses sorbet to provide both static and runtime type checking.
You can learn more over at the sorbet documentation.
Running Tests
The tests are written with rspec. Two gemfile variants are maintained in gemfiles/ for testing with different cryptographic backends:
standalone.gemfile— OpenSSL-only (v3 support)rbnacl.gemfile— includes rbnacl with libsodium (v4 support)
bin/setup # Run standalone tests (v3, OpenSSL-only) bundle exec rspec # Run rbnacl tests (v4, requires libsodium) BUNDLE_GEMFILE=gemfiles/rbnacl.gemfile bundle exec rspec
Updating RBI Files
To check that RBI files for gems are up-to-date:
BUNDLE_GEMFILE=gemfiles/rbnacl.gemfile bundle exec bin/tapioca gems --verifyTo update RBI files for gems:
BUNDLE_GEMFILE=gemfiles/rbnacl.gemfile bundle exec bin/tapioca gems BUNDLE_GEMFILE=gemfiles/rbnacl.gemfile bundle exec bin/tapioca annotations
Contributing
Bug reports and pull requests are welcome on GitHub.
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Paseto project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.