Skip to content

Commit

Permalink
🔏
Browse files Browse the repository at this point in the history
  • Loading branch information
m-o-e committed May 1, 2020
0 parents commit 8208dbe
Show file tree
Hide file tree
Showing 11 changed files with 517 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
21 changes: 21 additions & 0 deletions .github/workflows/crystal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Build

on: [push]

jobs:
build:
runs-on: ubuntu-latest

container:
image: crystallang/crystal

steps:
- uses: actions/checkout@v2
- name: Install packages
run: apt update && apt install -y wget
- name: Patch shard.yml until https://github.com/didactic-drunk/zstd.cr/pull/2 is released
run: "sed -i 's/version: ~> 1.1.0/branch: master/' shard.yml"
- name: Install dependencies
run: shards install
- name: Run tests
run: make ci
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf
.DS_Store

# Libraries don't need dependency lock
# Dependencies will be locked in applications that use them
/shard.lock
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2020 [email protected]

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
20 changes: 20 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.PHONY: docs

ci: test

test: lint
@crystal spec

lint: bin/ameba
@bin/ameba

docs:
@crystal doc

# Run this to initialize your development environment
install:
shards

bin/ameba:
@make install

97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Suzuri
![Build](https://github.com/busyloop/suzuri/workflows/Build/badge.svg) [![GitHub](https://img.shields.io/github/license/busyloop/suzuri)](https://en.wikipedia.org/wiki/MIT_License) [![GitHub release](https://img.shields.io/github/release/busyloop/suzuri.svg)](https://github.com/busyloop/suzuri/releases)

Suzuri is a secure and easy to use token format that employs
[IETF XChaCha20-Poly1305 AEAD](https://libsodium.gitbook.io/doc/secret-key_cryptography/aead/chacha20-poly1305/ietf_chacha20-poly1305_construction) symmetric encryption to
create authenticated, encrypted, tamperproof tokens.

It compresses and encrypts an arbitrary sequence of bytes,
then encodes the result to url-safe Base64.

Suzuri tokens can be used as a secure alternative to JWT
or for any type of general purpose message passing.


## Installation

1. Add the dependency to your `shard.yml`:

```yaml
dependencies:
suzuri:
github: busyloop/suzuri
```
2. Run `shards install`

## Documentation

* [API Documentation](https://busyloop.github.io/suzuri/Suzuri.html)


## Usage

```crystal
require "suzuri"
TEST_KEY = "TheKeyLengthMustBeThirtyTwoBytes"
## Encode
token_str = Suzuri.encode("hello world", TEST_KEY) # => "(url-safe base64)"
## Decode
token = Suzuri.decode(token_str, TEST_KEY) # => Suzuri::Token
token.to_s # => "hello world"
token.timestamp # => 2020-01-01 01:23:45.0 UTC
## Decode with a TTL constraint
token_str = Suzuri.encode("hello world", TEST_KEY) # => "(url-safe base64)"
sleep 5
Suzuri.decode(token_str, TEST_KEY, 2.seconds) # => Suzuri::Error::TokenExpired
```

## Usage (with [JSON::Serializable](https://crystal-lang.org/api/0.34.0/JSON/Serializable.html))

```crystal
require "suzuri/json_serializable"
TEST_KEY = "TheKeyLengthMustBeThirtyTwoBytes"
class Person
include JSON::Serializable
@[JSON::Field]
property name : String
def initialize(@name)
end
end
bob = Person.new(name: "bob")
token_str = bob.to_suzuri(TEST_KEY)
bob2 = Person.from_suzuri(token_str, TEST_KEY)
bob2.name # => "bob"
```


## Compression

By default Suzuri applies zstd compression before encryption when the
payload is larger than 512 bytes. The compression threshold and level
can be chosen at runtime.


## Contributing

1. Fork it (<https://github.com/busyloop/suzuri/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request

## Credits

Suzuri is inspired by (but not compatible to) [Branca](https://github.com/tuupola/branca-spec/)-tokens. The underlying encryption is identical.
Suzuri adds compression support and serializes to url-safe Base64 instead of Base62.

29 changes: 29 additions & 0 deletions shard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: suzuri
version: 1.0.0

authors:
- moe <[email protected]>

description: |
Authenticated and encrypted tokens
crystal: 0.34.0

license: MIT

dependencies:
sodium:
github: didactic-drunk/sodium.cr
version: ~> 1.1.1

zstd:
github: didactic-drunk/zstd.cr
version: ~> 1.1.0

development_dependencies:
timecop:
github: crystal-community/timecop.cr

ameba:
github: crystal-ameba/ameba
version: ~> 0.12.0
4 changes: 4 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
require "spec"
require "json"
require "timecop"
require "../src/suzuri/json_serializable"
130 changes: 130 additions & 0 deletions spec/suzuri_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
require "./spec_helper"

TEST_KEY = "TheKeyLengthMustBeThirtyTwoBytes"

class JsonDemo
include JSON::Serializable

@[JSON::Field]
property text : String

@[JSON::Field]
property float : Float64

@[JSON::Field]
property time : Time

def initialize(@text, @float, @time)
end
end

describe Suzuri do
it "decodes what it previously encoded" do
token = Suzuri.encode("hello world", TEST_KEY)
decoded = Suzuri.decode(token, TEST_KEY)
String.new(decoded.payload).should eq "hello world"
end

it "compresses the payload above a size-threshold" do
payload = "x" * 16384

token_nc = Suzuri.encode(payload, TEST_KEY, compress_threshold: UInt64::MAX)
token_c = Suzuri.encode(payload, TEST_KEY, compress_threshold: 0)

token_c.size.should be < token_nc.size

# ensure both tokens decode
Suzuri.decode(token_nc, TEST_KEY).to_s.should eq payload
Suzuri.decode(token_c, TEST_KEY).to_s.should eq payload
end

it "raises on encode when key is not 32 bytes long" do
expect_raises(ArgumentError, /key size mismatch/) do
Suzuri.encode("hello world", "too short")
end

expect_raises(ArgumentError, /key size mismatch/) do
Suzuri.encode("hello world", TEST_KEY + "too long")
end
end

it "raises on decode when key is not 32 bytes long" do
token = Suzuri.encode("hello world", TEST_KEY)
expect_raises(ArgumentError, /key size mismatch/) do
Suzuri.decode(token, "too short")
end

expect_raises(ArgumentError, /key size mismatch/) do
Suzuri.decode(token, TEST_KEY + "too long")
end
end

it "includes a timestamp with encoded tokens" do
Timecop.freeze(Time.utc(1990,1,1)) do |frozen_time|
token = Suzuri.encode("hello world", TEST_KEY)
decoded = Suzuri.decode(token, TEST_KEY)
decoded.timestamp.should eq frozen_time
end
end

it "allows encoded timestamp to be overridden" do
Timecop.freeze(Time.utc(1990,1,1)) do
token = Suzuri.encode("hello world", TEST_KEY, Time.utc(2000,1,1))
decoded = Suzuri.decode(token, TEST_KEY)
decoded.timestamp.should eq Time.utc(2000,1,1)
end
end

it "raises on decode when decryption fails (e.g. wrong key)" do
token = Suzuri.encode("hello world", TEST_KEY, Time.utc(2000,1,1))
expect_raises(Suzuri::Error::DecryptionFailed) do
Suzuri.decode(token, "WrongSecretxxxxxxxxxxxxxxxxxxxxx")
end
end

it "raises on decode when ttl is expired" do
token = Suzuri.encode("hello world", TEST_KEY, Time.utc(2000,1,1))
expect_raises(Suzuri::Error::TokenExpired) do
Suzuri.decode(token, TEST_KEY, 5.seconds)
end
end

it "raises on decode when input is not base64" do
not_a_token = "I'm not a token"
expect_raises(Suzuri::Error::MalformedInput) do
Suzuri.decode(not_a_token, TEST_KEY)
end
end

it "raises on decode when base64 content isn't a suzuri token" do
not_a_token = Base64.urlsafe_encode("I'm not a token")
expect_raises(Suzuri::Error::MalformedInput) do
Suzuri.decode(not_a_token, TEST_KEY)
end
end
end

describe JSON::Serializable do
it "encodes/decodes JSON::Serializable objects via to_suzuri/from_suzuri" do
demo = JsonDemo.new(text: "hello world", float: 0.42, time: Time.utc(1,1,1))

token = demo.to_suzuri(TEST_KEY)

decoded = JsonDemo.from_suzuri(token, TEST_KEY)
decoded.text.should eq demo.text
decoded.float.should eq demo.float
decoded.time.should eq demo.time
end

it "decodes JSON::Serializable objects with timestamp via from_suzuri_with_timestamp" do
demo = JsonDemo.new(text: "hello world", float: 0.42, time: Time.utc(1,1,1))

token = demo.to_suzuri(TEST_KEY)

decoded, timestamp = JsonDemo.from_suzuri_with_timestamp(token, TEST_KEY)
decoded.text.should eq demo.text
decoded.float.should eq demo.float
decoded.time.should eq demo.time
timestamp.should be_a Time
end
end
Loading

0 comments on commit 8208dbe

Please sign in to comment.