diff --git a/.gitignore b/.gitignore index 5e1422c..55e776b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store *.gem *.rbc /.config @@ -9,6 +10,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +benchmark/ # Used by dotenv library to load environment variables. # .env @@ -42,9 +44,11 @@ build-iPhoneSimulator/ # for a library or gem, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# Gemfile.lock -# .ruby-version -# .ruby-gemset +Gemfile.lock +.ruby-version +.ruby-gemset # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc +/.byebug_history +/.yardopts diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..eaa3bed --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: ruby +bundler_args: --without debug +script: "bundle exec rspec spec" +before_install: + - 'gem update --system --conservative || (gem i "rubygems-update:~>2.7" --no-document && update_rubygems)' + - 'gem update bundler --conservative' +env: + - CI=true +rvm: + - 2.2.2 + - 2.3 + - 2.4 + - 2.5 + - 2.6 + - jruby + - rbx-3 +cache: bundler +sudo: false +matrix: + allow_failures: + - rvm: jruby + - rvm: rbx-3 +dist: trusty diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..f6877e7 --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +* Gregg Kellogg diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b89315d --- /dev/null +++ b/Gemfile @@ -0,0 +1,13 @@ +source "https://rubygems.org" +gemspec + +group :development, :test do + gem 'simplecov', require: false, platform: :mri + gem 'coveralls', require: false, platform: :mri + gem 'benchmark-ips' + gem 'rake' +end + +group :debug do + gem "byebug", platforms: :mri +end diff --git a/README.md b/README.md index 1e158f5..2ff47dc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,92 @@ # json-canonicalization An implementation of the JSON Canonicalization Scheme for Ruby + +Implements version 5 of [draft-rundgren-json-canonicalization-scheme-05](https://tools.ietf.org/html/draft-rundgren-json-canonicalization-scheme-05#page-5). + +[![Gem Version](https://badge.fury.io/rb/json-canonicalization.png)](http://badge.fury.io/rb/json-canonicalization) +[![Build Status](https://travis-ci.org/dryruby/json-canonicalization.png?branch=master)](http://travis-ci.org/dryruby/json-canonicalization) +[![Coverage Status](https://coveralls.io/repos/dryruby/json-canonicalization/badge.svg)](https://coveralls.io/r/dryruby/json-canonicalization) + +# Description + +Cryptographic operations like hashing and signing depend on that the target +data does not change during serialization, transport, or parsing. +By applying the rules defined by JCS (JSON Canonicalization Scheme), +data provided in the JSON [[RFC8259](https://tools.ietf.org/html/rfc8259)] +format can be exchanged "as is", while still being subject to secure cryptographic operations. +JCS achieves this by building on the serialization formats for JSON +primitives as defined by ECMAScript [[ES6](https://www.ecma-international.org/ecma-262/6.0/index.html)], +constraining JSON data to the
I-JSON [[RFC7493](https://tools.ietf.org/html//rfc7493)] subset, +and through a platform independent property sorting scheme. + +Working document: https://cyberphone.github.io/ietf-json-canon
+Published IETF Draft: https://tools.ietf.org/html/draft-rundgren-json-canonicalization-scheme-05 + +The JSON Canonicalization Scheme concept in a nutshell: +- Serialization of primitive JSON data types using methods compatible with ECMAScript's `JSON.stringify()` +- Lexicographic sorting of JSON `Object` properties in a *recursive* process +- JSON `Array` data is also subject to canonicalization, *but element order remains untouched* + +### Sample Input: +```code +{ + "numbers": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] +} +``` +### Expected Output: +```code +{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\u000f\nA'B\"\\\\\"/"} +``` +## Usage +The library accepts Ruby input and generates canonical JSON via the `#to_json_c14n` method. This is based on the standard JSON gem's version of `#to_json` with overloads for `Hash`, `String` and `Numeric` + +```ruby +data = { + "numbers" => [ + 333333333.3333333, + 1.0e+30, + 4.5, + 0.002, + 1.0e-27 + ], + "string" => "€$\u000F\nA'B\"\\\\\"/", + "literals" => [nil, true, false] +} + +puts data.to_json_c14n +=> +``` + +## Documentation +Full documentation available on [RubyDoc](http://rubydoc.info/gems/json-canonicalization/file/README.md) + +### Principal Classes +* {JSON::Canonicalization} + +## Dependencies +* [Ruby](http://ruby-lang.org/) (>= 2.2.2) +* [JSON](https://rubygems.org/gems/json) (>= 2.1) + +## Author +* [Gregg Kellogg](http://github.com/gkellogg) - + +## Contributing +* Do your best to adhere to the existing coding conventions and idioms. +* Don't use hard tabs, and don't leave trailing whitespace on any line. +* Do document every method you add using [YARD][] annotations. Read the + [tutorial][YARD-GS] or just look at the existing code for examples. +* Don't touch the `json-ld.gemspec`, `VERSION` or `AUTHORS` files. If you need to + change them, do so on your private branch only. +* Do feel free to add yourself to the `CREDITS` file and the corresponding + list in the the `README`. Alphabetical order applies. +* Do note that in order for us to merge any non-trivial changes (as a rule + of thumb, additions larger than about 15 lines of code), we need an + explicit [public domain dedication][PDD] on record from you. + +##License + +This is free and unencumbered public domain software. For more information, +see or the accompanying {file:UNLICENSE} file. + diff --git a/Rakefile b/Rakefile new file mode 100755 index 0000000..280845c --- /dev/null +++ b/Rakefile @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +$:.unshift(File.expand_path(File.join(File.dirname(__FILE__), 'lib'))) +require 'rubygems' + +namespace :gem do + desc "Build the json-canonicalization-#{File.read('VERSION').chomp}.gem file" + task :build do + sh "gem build json-canonicalization.gemspec && mv json-canonicalization-#{File.read('VERSION').chomp}.gem pkg/" + end + + desc "Release the json-canonicalization-#{File.read('VERSION').chomp}.gem file" + task :release do + sh "gem push pkg/json-canonicalization-#{File.read('VERSION').chomp}.gem" + end +end + +desc 'Default: run specs.' +task default: :spec +task specs: :spec + +require 'rspec/core/rake_task' +desc 'Run specifications' +RSpec::Core::RakeTask.new do |spec| + spec.rspec_opts = %w(--options spec/spec.opts) if File.exists?('spec/spec.opts') +end + +desc "Run specifications for continuous integration" +RSpec::Core::RakeTask.new("spec:ci") do |spec| + spec.rspec_opts = %w(--options spec/spec.opts) if File.exists?('spec/spec.opts') +end diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/examples/c14n.json b/examples/c14n.json new file mode 100644 index 0000000..0a6148c --- /dev/null +++ b/examples/c14n.json @@ -0,0 +1,6 @@ +{ + "numbers": [333333333.33333329, 1E30, 4.50, + 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] +} diff --git a/json-canonicalization.gemspec b/json-canonicalization.gemspec new file mode 100755 index 0000000..4ea94ae --- /dev/null +++ b/json-canonicalization.gemspec @@ -0,0 +1,26 @@ +#!/usr/bin/env ruby -rubygems +# -*- encoding: utf-8 -*- + +Gem::Specification.new do |gem| + gem.version = File.read('VERSION').chomp + gem.date = File.mtime('VERSION').strftime('%Y-%m-%d') + + gem.name = "json-canonicalization" + gem.homepage = "http://github.com/dryruby/json-canonicalization" + gem.license = 'Unlicense' + gem.summary = "JSON Canonicalization for Ruby." + gem.description = "JSON::Canonicalization generates canonical JSON output from Ruby objects." + + gem.authors = ['Gregg Kellogg'] + + gem.platform = Gem::Platform::RUBY + gem.files = %w(AUTHORS README.md LICENSE VERSION) + Dir.glob('lib/**/*.rb') + gem.test_files = Dir.glob('spec/**/*.rb') + Dir.glob('spec/**/*.json') + + gem.required_ruby_version = '>= 2.2.2' + gem.requirements = [] + gem.add_development_dependency 'rspec', '~> 3.8' + gem.add_development_dependency 'yard' , '~> 0.9' + + gem.post_install_message = nil +end diff --git a/lib/json/canonicalization.rb b/lib/json/canonicalization.rb new file mode 100644 index 0000000..3967ae9 --- /dev/null +++ b/lib/json/canonicalization.rb @@ -0,0 +1,83 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +$:.unshift(File.expand_path("../ld", __FILE__)) +require 'json' + +module JSON + ## + # `JSON::Canonicalization` generates canonical JSON output from Ruby objects + module Canonicalization + autoload :VERSION, 'json/ld/version' + end +end + +class Object + # Default canonicalization output for Ruby objects + # @return [String] + def to_json_c14n + self.to_json + end +end + +class Array + def to_json_c14n + '[' + self.map(&:to_json_c14n).join(',') + ']' + end +end + +class Numeric + def to_json_c14n + raise RangeError if self.is_a?(Float) && (self.nan? || self.infinite?) + return "0" if self.zero? + num = self + if num < 0 + num, sign = -num, '-' + end + native_rep = "%.15E" % num + decimal, exponential = native_rep.split('E') + exp_val = exponential.to_i + exponential = exp_val > 0 ? ('+' + exp_val.to_s) : exp_val.to_s + + integral, fractional = decimal.split('.') + fractional = fractional.sub(/0+$/, '') # Remove trailing zeros + + if exp_val > 0 && exp_val < 21 + while exp_val > 0 + integral += fractional.to_s[0] || '0' + fractional = fractional.to_s[1..-1] + exp_val -= 1 + end + exponential = nil + elsif exp_val == 0 + exponential = nil + elsif exp_val < 0 && exp_val > -7 + # Small numbers are shown as 0.etc with e-6 as lower limit + fractional, integral, exponential = integral + fractional.to_s, '0', nil + fractional = ("0" * (-exp_val - 1)) + fractional + end + + fractional = nil if fractional.to_s.empty? + sign.to_s + integral + (fractional ? ".#{fractional}" : '') + (exponential ? "e#{exponential}" : '') + end +end + +class Hash + # Output JSON with keys sorted lexicographically + # @return [String] + def to_json_c14n + "{" + self. + keys. + sort_by {|k| k.encode(Encoding::UTF_16)}. + map {|k| k.to_json_c14n + ':' + self[k].to_json_c14n} + .join(',') + + '}' + end +end + +class String + # Output JSON with control characters escaped + # @return [String] + def to_json_c14n + self.to_json + end +end diff --git a/lib/json/canonicalization/version.rb b/lib/json/canonicalization/version.rb new file mode 100644 index 0000000..c8bceeb --- /dev/null +++ b/lib/json/canonicalization/version.rb @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +module JSON::Canonicalization::VERSION + VERSION_FILE = File.join(File.expand_path(File.dirname(__FILE__)), "..", "..", "..", "VERSION") + MAJOR, MINOR, TINY, EXTRA = File.read(VERSION_FILE).chomp.split(".") + + STRING = [MAJOR, MINOR, TINY, EXTRA].compact.join('.') + + ## + # @return [String] + def self.to_s() STRING end + + ## + # @return [String] + def self.to_str() STRING end + + ## + # @return [Array(Integer, Integer, Integer)] + def self.to_a() STRING.split(".") end +end diff --git a/spec/c14n_spec.rb b/spec/c14n_spec.rb new file mode 100644 index 0000000..5c8df05 --- /dev/null +++ b/spec/c14n_spec.rb @@ -0,0 +1,11 @@ +require_relative 'spec_helper' + +describe "conversions" do + Dir.glob(File.expand_path("../input/*.json", __FILE__)).each do |input| + it "converts #{input.split('/').last}" do + expected = File.read(input.sub('input', 'output')) + data = JSON.parse(File.read(input)) + expect(data.to_json_c14n).to eq expected + end + end +end diff --git a/spec/input/arrays.json b/spec/input/arrays.json new file mode 100644 index 0000000..9154bdf --- /dev/null +++ b/spec/input/arrays.json @@ -0,0 +1,8 @@ +[ + 56, + { + "d": true, + "10": null, + "1": [ ] + } +] \ No newline at end of file diff --git a/spec/input/french.json b/spec/input/french.json new file mode 100644 index 0000000..d4ad2e4 --- /dev/null +++ b/spec/input/french.json @@ -0,0 +1,6 @@ +{ + "peach": "This sorting order", + "péché": "is wrong according to French", + "pêche": "but canonicalization MUST", + "sin": "ignore locale" +} \ No newline at end of file diff --git a/spec/input/structures.json b/spec/input/structures.json new file mode 100644 index 0000000..eb71efb --- /dev/null +++ b/spec/input/structures.json @@ -0,0 +1,8 @@ +{ + "1": {"f": {"f": "hi","F": 5} ,"\n": 56.0}, + "10": { }, + "": "empty", + "a": { }, + "111": [ {"e": "yes","E": "no" } ], + "A": { } +} \ No newline at end of file diff --git a/spec/input/unicode.json b/spec/input/unicode.json new file mode 100644 index 0000000..9ec5cc7 --- /dev/null +++ b/spec/input/unicode.json @@ -0,0 +1,3 @@ +{ + "Unnormalized Unicode":"A\u030a" +} \ No newline at end of file diff --git a/spec/input/values.json b/spec/input/values.json new file mode 100644 index 0000000..f7712c2 --- /dev/null +++ b/spec/input/values.json @@ -0,0 +1,5 @@ +{ + "numbers": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] +} \ No newline at end of file diff --git a/spec/input/wierd.json b/spec/input/wierd.json new file mode 100644 index 0000000..940bec4 --- /dev/null +++ b/spec/input/wierd.json @@ -0,0 +1,11 @@ +{ + "\u20ac": "Euro Sign", + "\r": "Carriage Return", + "\u000a": "Newline", + "1": "One", + "\u0080": "Control\u007f", + "\ud83d\ude02": "Smiley", + "\u00f6": "Latin Small Letter O With Diaeresis", + "\ufb33": "Hebrew Letter Dalet With Dagesh", + "": "Browser Challenge" +} \ No newline at end of file diff --git a/spec/number_spec.rb b/spec/number_spec.rb new file mode 100644 index 0000000..5624477 --- /dev/null +++ b/spec/number_spec.rb @@ -0,0 +1,37 @@ +require_relative 'spec_helper' + +describe "conversions" do + { + -1/0.0 => RangeError, + -9007199254740992 => '-9007199254740992', + 0 => '0', + 0.000001 => '0.000001', + 0/0.0 => RangeError, + 1/0.0 => RangeError, + 1e+21 => '1e+21', + 9.999999999999997e+22 => '9.999999999999997e+22', + 9.999999999999997e-7 => '9.999999999999997e-7', + 9007199254740992 => '9007199254740992', + 9007199254740994 => '9007199254740994', + 9007199254740996 => '9007199254740996', + 999999999999999700000 => '999999999999999700000', + 999999999999999900000 => '999999999999999900000', + # -5e-324 => '-5e-324', # Outside Ruby Range + # 1.0000000000000001e+23 => '1.0000000000000001e+23', # Outside Ruby Range + # 295147905179352830000 => '295147905179352830000', # Outside Ruby Range + #-1.7976931348623157e+308 => '-1.7976931348623157e+308', # Outside Ruby Range + #1.7976931348623157e+308 => '1.7976931348623157e+308', # Outside Ruby Range + #1e+23 => '1e+23', # Outside Ruby + #5e-324 => '5e-324', # Outside Ruby Range + }.each do |data, expected| + if expected.is_a?(String) + it "converts #{data} to #{expected}" do + expect(data.to_json_c14n).to eq expected + end + else + it "raises #{expected} for #{data}" do + expect {data.to_json_c14n}.to raise_error(expected) + end + end + end +end diff --git a/spec/output/arrays.json b/spec/output/arrays.json new file mode 100644 index 0000000..5efb93d --- /dev/null +++ b/spec/output/arrays.json @@ -0,0 +1 @@ +[56,{"1":[],"10":null,"d":true}] \ No newline at end of file diff --git a/spec/output/french.json b/spec/output/french.json new file mode 100644 index 0000000..2e15cd1 --- /dev/null +++ b/spec/output/french.json @@ -0,0 +1 @@ +{"peach":"This sorting order","péché":"is wrong according to French","pêche":"but canonicalization MUST","sin":"ignore locale"} \ No newline at end of file diff --git a/spec/output/structures.json b/spec/output/structures.json new file mode 100644 index 0000000..dc21e24 --- /dev/null +++ b/spec/output/structures.json @@ -0,0 +1 @@ +{"":"empty","1":{"\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}} \ No newline at end of file diff --git a/spec/output/unicode.json b/spec/output/unicode.json new file mode 100644 index 0000000..ee60fd1 --- /dev/null +++ b/spec/output/unicode.json @@ -0,0 +1 @@ +{"Unnormalized Unicode":"Å"} \ No newline at end of file diff --git a/spec/output/values.json b/spec/output/values.json new file mode 100644 index 0000000..29b720b --- /dev/null +++ b/spec/output/values.json @@ -0,0 +1 @@ +{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\u000f\nA'B\"\\\\\"/"} \ No newline at end of file diff --git a/spec/output/wierd.json b/spec/output/wierd.json new file mode 100644 index 0000000..62c83a3 --- /dev/null +++ b/spec/output/wierd.json @@ -0,0 +1 @@ +{"\n":"Newline","\r":"Carriage Return","1":"One","":"Browser Challenge","€":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😂":"Smiley","דּ":"Hebrew Letter Dalet With Dagesh"} \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..9db2b16 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,24 @@ +$:.unshift(File.join("../../lib", __FILE__)) + +require "bundler/setup" +require 'rspec' + +begin + require 'simplecov' + require 'coveralls' unless ENV['NOCOVERALLS'] + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + (Coveralls::SimpleCov::Formatter unless ENV['NOCOVERALLS']) + ]) + SimpleCov.start do + add_filter "/spec/" + end +rescue LoadError +end + +require 'json/canonicalization' + +::RSpec.configure do |c| + c.filter_run focus: true + c.run_all_when_everything_filtered = true +end