Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions tokencrypt.gemspec → 3ncr.gemspec
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# frozen_string_literal: true

require_relative "lib/tokencrypt/version"
require_relative "lib/threencr/version"

Gem::Specification.new do |spec|
spec.name = "tokencrypt"
spec.version = Tokencrypt::VERSION
spec.name = "3ncr"
spec.version = Threencr::VERSION
spec.authors = ["3ncr.org"]
spec.summary = "Ruby implementation of the 3ncr.org v1 string encryption standard (AES-256-GCM)."
spec.description = <<~DESC
Expand Down
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# tokencrypt (3ncr.org)
# 3ncr (Ruby)

[![Test](https://github.com/3ncr/tokencrypt-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/3ncr/tokencrypt-ruby/actions/workflows/test.yml)
[![Gem Version](https://img.shields.io/gem/v/tokencrypt.svg)](https://rubygems.org/gems/tokencrypt)
[![Gem Version](https://img.shields.io/gem/v/3ncr.svg)](https://rubygems.org/gems/3ncr)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/3ncr/tokencrypt-ruby/badge)](https://scorecard.dev/viewer/?uri=github.com/3ncr/tokencrypt-ruby)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

Expand All @@ -24,13 +24,13 @@ This is the official Ruby implementation.
Add to your `Gemfile`:

```ruby
gem "tokencrypt"
gem "3ncr"
```

Or install directly:

```bash
gem install tokencrypt
gem install 3ncr
```

Requires Ruby 3.1+.
Expand All @@ -47,30 +47,30 @@ If you already have a 32-byte AES-256 key, skip the KDF and pass it directly.

```ruby
require "securerandom"
require "tokencrypt"
require "3ncr"

key = SecureRandom.bytes(32) # or load from an env variable / secret store
tc = Tokencrypt::TokenCrypt.from_raw_key(key)
tc = Threencr::TokenCrypt.from_raw_key(key)
```

For a high-entropy secret that is not already 32 bytes (e.g. a random API
token), hash it through SHA3-256:

```ruby
tc = Tokencrypt::TokenCrypt.from_sha3("some-high-entropy-api-token")
tc = Threencr::TokenCrypt.from_sha3("some-high-entropy-api-token")
```

### Recommended: Argon2id (passwords / low-entropy secrets)

For passwords or passphrases, use `Tokencrypt::TokenCrypt.from_argon2id`. It
For passwords or passphrases, use `Threencr::TokenCrypt.from_argon2id`. It
uses the parameters recommended by the
[3ncr.org v1 spec](https://3ncr.org/1/#kdf) (`m=19456 KiB, t=2, p=1`). The salt
must be at least 16 bytes.

```ruby
require "tokencrypt"
require "3ncr"

tc = Tokencrypt::TokenCrypt.from_argon2id(
tc = Threencr::TokenCrypt.from_argon2id(
"correct horse battery staple",
"0123456789abcdef"
)
Expand All @@ -86,7 +86,7 @@ yourself and pass the result to `from_raw_key`:

```ruby
require "openssl"
require "tokencrypt"
require "3ncr"

key = OpenSSL::KDF.pbkdf2_hmac(
secret,
Expand All @@ -95,7 +95,7 @@ key = OpenSSL::KDF.pbkdf2_hmac(
length: 32,
hash: OpenSSL::Digest.new("SHA3-256")
)
tc = Tokencrypt::TokenCrypt.from_raw_key(key)
tc = Threencr::TokenCrypt.from_raw_key(key)
```

### Encrypt / decrypt
Expand All @@ -113,7 +113,7 @@ tc.decrypt_if_3ncr(encrypted) # => plaintext
through it regardless of whether it was encrypted.

Decryption failures (bad tag, truncated input, malformed base64) raise
`Tokencrypt::Error`.
`Threencr::Error`.

## Cross-implementation interop

Expand All @@ -128,7 +128,7 @@ the [Go](https://github.com/3ncr/tokencrypt),
32-byte AES key behind those vectors was originally derived via PBKDF2-SHA3-256
with `secret = "a"`, `salt = "b"`, `iterations = 1000`; the tests hardcode the
resulting key and verify the AES-256-GCM envelope round-trips exactly. See
`test/test_tokencrypt.rb`.
`test/test_3ncr.rb`.

## Development

Expand Down
4 changes: 2 additions & 2 deletions lib/tokencrypt.rb → lib/3ncr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
require "openssl"
require "securerandom"

require_relative "tokencrypt/version"
require_relative "threencr/version"

# 3ncr.org v1 string encryption.
#
Expand All @@ -15,7 +15,7 @@
# entropy of the input secret.
#
# See https://3ncr.org/1/ for the full specification.
module Tokencrypt
module Threencr
HEADER_V1 = "3ncr.org/1#"

AES_KEY_SIZE = 32
Expand Down
2 changes: 1 addition & 1 deletion lib/tokencrypt/version.rb → lib/threencr/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Tokencrypt
module Threencr
VERSION = "1.0.0"
end
62 changes: 31 additions & 31 deletions test/test_tokencrypt.rb → test/test_3ncr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

require "minitest/autorun"
require "securerandom"
require "tokencrypt"
require "3ncr"

# Canonical v1 envelope test vectors -- shared with Go, Node, PHP, Python,
# Rust, Java, and C# implementations. The 32-byte AES key was originally
Expand All @@ -26,17 +26,17 @@

class TestCanonicalVectors < Minitest::Test
def test_decrypts_canonical_vectors
tc = Tokencrypt::TokenCrypt.from_raw_key(CANONICAL_KEY)
tc = Threencr::TokenCrypt.from_raw_key(CANONICAL_KEY)
CANONICAL_VECTORS.each do |plaintext, encrypted|
assert_equal plaintext, tc.decrypt_if_3ncr(encrypted)
end
end

def test_round_trip_canonical_plaintexts
tc = Tokencrypt::TokenCrypt.from_raw_key(CANONICAL_KEY)
tc = Threencr::TokenCrypt.from_raw_key(CANONICAL_KEY)
CANONICAL_VECTORS.each do |plaintext, _|
enc = tc.encrypt_3ncr(plaintext)
assert enc.start_with?(Tokencrypt::HEADER_V1), "missing v1 header in #{enc}"
assert enc.start_with?(Threencr::HEADER_V1), "missing v1 header in #{enc}"
assert_equal plaintext, tc.decrypt_if_3ncr(enc)
end
end
Expand All @@ -53,7 +53,7 @@ class TestRoundTripEdgeCases < Minitest::Test
].freeze

def test_round_trip
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
PLAINTEXTS.each do |plaintext|
assert_equal plaintext, tc.decrypt_if_3ncr(tc.encrypt_3ncr(plaintext))
end
Expand All @@ -62,19 +62,19 @@ def test_round_trip

class TestEnvelopePassthrough < Minitest::Test
def test_non_3ncr_returned_unchanged
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
assert_equal "plain config value", tc.decrypt_if_3ncr("plain config value")
end

def test_empty_string_returned_unchanged
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
assert_equal "", tc.decrypt_if_3ncr("")
end
end

class TestIVUniqueness < Minitest::Test
def test_two_encrypts_differ
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
a = tc.encrypt_3ncr("same plaintext")
b = tc.encrypt_3ncr("same plaintext")
refute_equal a, b
Expand All @@ -83,61 +83,61 @@ def test_two_encrypts_differ

class TestTamperDetection < Minitest::Test
def test_flipped_byte_in_payload_is_rejected
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
enc = tc.encrypt_3ncr("sensitive value")
body = enc[Tokencrypt::HEADER_V1.length..]
body = enc[Threencr::HEADER_V1.length..]
idx = body.length / 2
flip = body[idx] == "A" ? "B" : "A"
flipped = Tokencrypt::HEADER_V1 + body[0...idx] + flip + body[(idx + 1)..]
assert_raises(Tokencrypt::Error) { tc.decrypt_if_3ncr(flipped) }
flipped = Threencr::HEADER_V1 + body[0...idx] + flip + body[(idx + 1)..]
assert_raises(Threencr::Error) { tc.decrypt_if_3ncr(flipped) }
end

def test_truncated_payload_is_rejected
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
assert_raises(Tokencrypt::Error) do
tc.decrypt_if_3ncr("#{Tokencrypt::HEADER_V1}AAAA")
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
assert_raises(Threencr::Error) do
tc.decrypt_if_3ncr("#{Threencr::HEADER_V1}AAAA")
end
end
end

class TestBase64PaddingRobustness < Minitest::Test
def test_decoder_accepts_padded_input
tc = Tokencrypt::TokenCrypt.from_raw_key(CANONICAL_KEY)
tc = Threencr::TokenCrypt.from_raw_key(CANONICAL_KEY)
plaintext, encrypted = CANONICAL_VECTORS.first
body = encrypted[Tokencrypt::HEADER_V1.length..]
body = encrypted[Threencr::HEADER_V1.length..]
padded = body + ("=" * ((-body.length) % 4))
assert_equal plaintext, tc.decrypt_if_3ncr(Tokencrypt::HEADER_V1 + padded)
assert_equal plaintext, tc.decrypt_if_3ncr(Threencr::HEADER_V1 + padded)
end

def test_encoder_emits_no_padding
tc = Tokencrypt::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
tc = Threencr::TokenCrypt.from_raw_key(SecureRandom.bytes(32))
enc = tc.encrypt_3ncr("some value")
refute_includes enc, "="
end
end

class TestKDFs < Minitest::Test
def test_raw_key_requires_32_bytes
assert_raises(ArgumentError) { Tokencrypt::TokenCrypt.from_raw_key("\x00" * 31) }
assert_raises(ArgumentError) { Tokencrypt::TokenCrypt.from_raw_key("\x00" * 33) }
assert_raises(ArgumentError) { Threencr::TokenCrypt.from_raw_key("\x00" * 31) }
assert_raises(ArgumentError) { Threencr::TokenCrypt.from_raw_key("\x00" * 33) }
end

def test_sha3_round_trip
tc = Tokencrypt::TokenCrypt.from_sha3("some-high-entropy-api-token")
tc = Threencr::TokenCrypt.from_sha3("some-high-entropy-api-token")
assert_equal "hello", tc.decrypt_if_3ncr(tc.encrypt_3ncr("hello"))
end

def test_sha3_matches_known_vector
# SHA3-256("a") = 80084bf2fba02475726feb2cab2d8215eab14bc6bdd8bfb2c8151257032ecd8b
expected_key = ["80084bf2fba02475726feb2cab2d8215eab14bc6bdd8bfb2c8151257032ecd8b"].pack("H*")
expected = Tokencrypt::TokenCrypt.from_raw_key(expected_key)
actual = Tokencrypt::TokenCrypt.from_sha3("a")
expected = Threencr::TokenCrypt.from_raw_key(expected_key)
actual = Threencr::TokenCrypt.from_sha3("a")
enc = expected.encrypt_3ncr("hello")
assert_equal "hello", actual.decrypt_if_3ncr(enc)
end

def test_argon2id_round_trip
tc = Tokencrypt::TokenCrypt.from_argon2id(
tc = Threencr::TokenCrypt.from_argon2id(
"correct horse battery staple", "0123456789abcdef"
)
CANONICAL_VECTORS.each do |plaintext, _|
Expand All @@ -147,17 +147,17 @@ def test_argon2id_round_trip

def test_argon2id_short_salt_rejected
assert_raises(ArgumentError) do
Tokencrypt::TokenCrypt.from_argon2id("secret", "short")
Threencr::TokenCrypt.from_argon2id("secret", "short")
end
end

def test_argon2id_wrong_secret_fails
salt = "0123456789abcdef"
tc = Tokencrypt::TokenCrypt.from_argon2id("right secret", salt)
tc = Threencr::TokenCrypt.from_argon2id("right secret", salt)
enc = tc.encrypt_3ncr("hello")

other = Tokencrypt::TokenCrypt.from_argon2id("wrong secret", salt)
assert_raises(Tokencrypt::Error) { other.decrypt_if_3ncr(enc) }
other = Threencr::TokenCrypt.from_argon2id("wrong secret", salt)
assert_raises(Threencr::Error) { other.decrypt_if_3ncr(enc) }
end

def test_argon2id_matches_cross_implementation_key
Expand All @@ -166,8 +166,8 @@ def test_argon2id_matches_cross_implementation_key
# below was computed from secret="correct horse battery staple",
# salt="0123456789abcdef" with m=19456, t=2, p=1.
expected_key = ["832e52b959b967b570ee4781f6c7bda7ced019ca266ac781fd2d94d4e853b0cd"].pack("H*")
expected = Tokencrypt::TokenCrypt.from_raw_key(expected_key)
actual = Tokencrypt::TokenCrypt.from_argon2id(
expected = Threencr::TokenCrypt.from_raw_key(expected_key)
actual = Threencr::TokenCrypt.from_argon2id(
"correct horse battery staple", "0123456789abcdef"
)
enc = expected.encrypt_3ncr("interop check")
Expand Down