Paseto
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
This repository includes a VSCode DevContainer configuration which automatically includes extensions for both Sorbet and Solargraph, and configures a docker image with libsodium.
After checking out the repo, run bin/setup
to install dependencies. If you are using the provided DevContainer, this happens automatically after the container image is first created.
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. Appraisal is used to ensure compatibility with 3rd party dependencies providing cryptographic features.
bundle install
appraisal install
# in parallel
appraisal rake
# or not
appraisal rake specs
Updating RBI Files
To check that RBI files for gems are up-to-date:
appraisal rbnacl bin/tapioca gems --verify
To update RBI files for gems:
appraisal rbnacl bin/tapioca gems
appraisal rbnacl 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.