Commits (21)
......@@ -7,3 +7,5 @@ spec/fixtures/manifests
spec/tmp
!spec/tmp/.keep
*.lock
.bundle/
vendor/
......@@ -6,15 +6,6 @@
- bundle exec rake tests
- bundle exec rake spec
# Test with version present on Debian Stretch
test:puppet4.8:
image: ruby:2.3
variables:
PUPPET_VERSION: "~> 4.8.2"
FACTER_VERSION: '~> 2.4.6'
HIERA_VERSION: '~> 3.2.0'
<<: *test_definition
# Test with version present on Debian Buster
test:puppet5.5:
image: ruby:2.5
......@@ -23,6 +14,10 @@ test:puppet5.5:
FACTER_VERSION: '~> 3.11.0'
<<: *test_definition
# Test with the latest Puppet release
test:puppetlatest:
<<: *test_definition
# Build and deploy docs on GitLab Pages
pages:
image: debian:stable
......
......@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format
is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this
project adheres to [Semantic Versioning](http://semver.org).
## [2.3.0](https://gitlab.com/shared-puppet-modules-group/tor/-/tags/2.3.0) (2020-11-01)
### Added
- Added support for Onion Service version 3 (thanks to ng!)
[Full Changelog](https://gitlab.com/shared-puppet-modules-group/tor/-/compare/2.2.0...2.3.0)
## [2.2.0](https://gitlab.com/shared-puppet-modules-group/tor/-/tags/2.2.0) (2020-10-26)
### Added
......
......@@ -2,10 +2,11 @@
source 'https://rubygems.org'
gem 'rake'
# 5.3.4 is currently broken
# https://github.com/rodjek/rspec-puppet/issues/647
gem 'puppet', ENV['PUPPET_VERSION'] || '< 5.3.4'
gem 'puppet', ENV['PUPPET_VERSION'] || '>= 6.0'
gem 'ed25519'
gem 'sha3', platform: :mri
gem 'sha3-pure-ruby', platform: :jruby
gem 'base32'
group :tests do
......
......@@ -60,25 +60,36 @@ For example, this will configure a tor bridge relay running on port 8080:
# Functions
This module comes with 2 functions specific to tor support. They require the
base32 gem to be installed on the master or wherever they are executed.
This module comes with 3 functions specific to tor support. They require the
base32, ed25519 and sha3 gem to be installed on the master or wherever they are
executed. For JRuby based installations such as puppetserver environments you
can use the sha3-pure-ruby instead of the C based library.
## onionv3_key
This functions generates an onion v3 key pair if not already existing. As
arguments, you need to pass a base directory and an indentifier (name) of the key.
The key pair will be looked up in a directory under <base_dir>/<name>.
As a result you will get a hash containing they secret key (hs_ed25519_secret_key),
the public key (hs_ed25519_public_key) and the onion hostname (hostname). The
latter will be without the `.onion` suffix.
If a key has already been created and exists under that directory, the content
of these files will be returned.
## onion_address
This function takes a 1024bit RSA private key as an argument and returns the
onion address for an onion service for that key.
At the moment, this function does not support v3 onions.
onion v2 address for an onion service for that key.
## generate_onion_key
This function takes a path (on the puppet master!) and an identifier for a key
and returns an array containing the matching onion address and the private key.
and returns an array containing the matching onion v2 address and the private key.
The private key either exists under the supplied `path/key_identifier` or is
being generated on the fly and stored under that path for the next execution.
At the moment, this function does not support v3 onions.
# Facts
## tor_hidden_services
......
......@@ -31,7 +31,6 @@ Puppet::Functions.create_function(:'tor::generate_onion_key') do
def default_impl(*args)
location = args.shift
identifier = args.shift
......@@ -58,7 +57,7 @@ Puppet::Functions.create_function(:'tor::generate_onion_key') do
oa
end
[ onion_address, private_key.to_s ]
[ onion_address,
Puppet::Pops::Types::PSensitiveType::Sensitive.new(private_key.to_s) ]
end
end
# Function that generates and stores a onionv3 key on the
# filesystem and returns the generated data
# If the key and data already exists, it will only read
# the data from disk.
#
# Result: { 'hs_ed25519_secret_key' => 'binary data',
# 'hs_ed25519_public_key' => 'binary_data',
# 'hostname' => 'ONIONV3_HOSTNAME.onion' }
require 'fileutils'
require 'ed25519'
require 'base32'
Puppet::Functions.create_function(:'tor::onionv3_key') do
dispatch :get do
param 'String', :base_dir
param 'String', :name
end
def get(base_dir, name)
path = File.join(base_dir,name)
unless File.directory?(path)
FileUtils.mkdir_p(path)
end
unless all_files_exist?(path)
generate_data(path)
end
res = read_data(path)
res['hs_ed25519_secret_key'] = Puppet::Pops::Types::PSensitiveType::Sensitive.new(res['hs_ed25519_secret_key'])
res
end
def all_files_exist?(path)
onionv3_filenames.all?{|f| File.readable?(File.join(path,f)) }
end
def read_data(path)
onionv3_filenames.inject({}) do |res,f|
res[f] = File.read(File.join(path,f)).chomp
res
end
end
def onionv3_filenames
@onionv3_filenames ||=[ 'hs_ed25519_secret_key',
'hs_ed25519_public_key',
'hostname']
end
def generate_data(path)
key = Ed25519::SigningKey.generate
pubkey = key.keypair[32...64]
onionname = onion_address(pubkey)
seckey = key.keypair[0...32]
extkey = extend_sk(seckey)
write_tagged_file("#{path}/hs_ed25519_secret_key", 'ed25519v1-secret', extkey)
write_tagged_file("#{path}/hs_ed25519_public_key", 'ed25519v1-public', pubkey)
File.open("#{path}/hostname", 'w') {|f| f.write "#{onionname}\n" }
end
def onion_address(pk, vers=3.chr)
pref = '.onion checksum'
v = pref + pk + vers
d = sha3_digest(v)
a = pk + d[0..1] + vers
Base32.encode(a).downcase + ".onion"
end
def extend_sk(sk)
sk = Digest::SHA512.digest(sk)
sk[0] = (sk[0].ord & 248).chr
sk[31] = (sk[31].ord & 63).chr
sk[31] = (sk[31].ord | 64).chr
sk
end
def write_tagged_file(file, tag, key)
File.open(file, 'wb') do |f|
pref = "== #{tag}: type0 =="
f.write pref
(32-pref.bytesize).times { f.write 0.chr }
f.write key
end
end
# dispatch onto a different library based
# on which environment we are running, as the
# sha3 c-extension can't be used on jruby, but the
# pure implementation delivers the same result
def sha3_digest(v)
if defined?(RUBY_ENGINE) && (RUBY_ENGINE == 'jruby')
require 'sha3-pure-ruby'
Digest::SHA3.new(256).digest(v)
else
require 'sha3'
c = SHA3::Digest::SHA256.new()
c.update(v)
c.digest
end
end
end
# @summary Extend basic Tor configuration with a snippet based configuration.
# Automap Hosts on Resolve module.
#
# @param ensure
# Whether this module should be used or not.
#
# @param automap_hosts_on_resolve
# Whether AutomapHostsOnResolve should be enabled or not.
# Default: true
#
define tor::daemon::automap_hosts_on_resolve(
Enum['present','absent'] $ensure = 'present',
Boolean $automap_hosts_on_resolve = true,
){
if $ensure == 'present' {
concat::fragment { "13.automaphostsonresolve.${name}":
content => epp('tor/torrc/13_automaphostsonresolve.epp', {
'automap_hosts_on_resolve' => $automap_hosts_on_resolve,
}),
order => '13',
target => $tor::config_file,
}
}
}
......@@ -4,6 +4,18 @@
# @example Make SSH available as a Tor service
# tor::daemon::onion_service { 'onion-ssh':
# ports => [ '22' ],
# v3 => true,
# }
#
# @example Make SSH available as a Tor service, using an existing key
# tor::daemon::onion_service { 'onion-ssh':
# ports => [ '22' ],
# v3 => true,
# v3_data => {
# 'hs_ed25519_secret_key' => 'your onion v3 private key',
# 'hs_ed25519_public_key' => 'your onion v3 public key',
# 'hostname' => 'vww7ybal4bd8szmgncyruucpgfkqahzddi38ktceo3ah8ngmcopnpyyd.onion',
# }
# }
#
# @param ensure
......@@ -25,6 +37,20 @@
# The onion address private key for the hidden service. Either specify this or
# $private_key_name and $private_key_store_path
#
# @param v3_data
# Use this parameter to specify an existing key pair and hostname for a
# v3 Hidden Service. Leave it undefined if you want puppet to generate them
# for you.
#
# @option v3_data [String] :hs_ed25519_secret_key
# ed25519 private key for the v3 Hidden Service.
#
# @option v3_data [String] :hs_ed25519_public_key
# ed25519 public key for the v3 Hidden Service.
#
# @option v3_data [String] :hostname
# Full onion hostname for the Hidden Service.
#
# @param private_key_name
# The name of the onion address private key file for the hidden service.
#
......@@ -32,14 +58,19 @@
# The path to directory where the onion address private key file is stored.
#
define tor::daemon::onion_service(
Enum['present', 'absent'] $ensure = 'present',
Optional[Array[String]] $ports = undef,
Stdlib::Unixpath $data_dir = $tor::data_dir,
Boolean $v3 = false,
Boolean $single_hop = false,
Optional[String] $private_key = undef,
String $private_key_name = $name,
Optional[String] $private_key_store_path = undef,
Enum['present', 'absent'] $ensure = 'present',
Array[String] $ports = [],
Stdlib::Unixpath $data_dir = $tor::data_dir,
Boolean $v3 = false,
Boolean $single_hop = false,
Optional[Sensitive[String[1]]] $private_key = undef,
Optional[Struct[{
'hs_ed25519_secret_key' => Sensitive[String[1]],
'hs_ed25519_public_key' => String[1],
'hostname' => String[1],
}]] $v3_data = undef,
String $private_key_name = $name,
Optional[String] $private_key_store_path = undef,
) {
$data_dir_path = "${data_dir}/${name}"
......@@ -57,46 +88,76 @@ define tor::daemon::onion_service(
}
if $single_hop {
file { "${data_dir_path}/onion_service_non_anonymous":
ensure => 'present',
ensure => file,
notify => Service['tor'];
}
}
}
if $private_key or ($private_key_name and $private_key_store_path) {
if $private_key and ($private_key_name and $private_key_store_path) {
fail('Either private_key OR (private_key_name AND private_key_store_path) must be set, but not all three of them')
}
if $private_key_store_path and $private_key_name {
$tmp = tor::generate_onion_key($private_key_store_path,$private_key_name)
$os_hostname = $tmp[0]
$real_private_key = $tmp[1]
} else {
$os_hostname = tor::onion_address($private_key)
$real_private_key = $private_key
}
include ::tor::daemon::params
if ($private_key or $v3_data) or ($private_key_name and $private_key_store_path) {
file{
$data_dir_path:
ensure => directory,
purge => true,
force => true,
recurse => true,
owner => $tor::daemon::params::user,
group => $tor::daemon::params::group,
mode => '0600',
require => Package['tor'];
"${data_dir_path}/private_key":
content => $real_private_key,
owner => $tor::daemon::params::user,
group => $tor::daemon::params::group,
mode => '0600',
notify => Service['tor'];
"${data_dir_path}/hostname":
content => "${os_hostname}.onion\n",
recurse => true;
}
if $ensure == 'present' {
include tor::daemon::params
File[$data_dir_path]{
ensure => directory,
owner => $tor::daemon::params::user,
group => $tor::daemon::params::group,
mode => '0600',
notify => Service['tor'];
require => Package['tor'],
}
if $v3 {
if $v3_data {
$real_v3_data = $v3_data
} else {
$real_v3_data = tor::onionv3_key($private_key_store_path,$private_key_name)
}
file{
default:
owner => $tor::daemon::params::user,
group => $tor::daemon::params::group,
mode => '0600',
notify => Service['tor'];
"${data_dir_path}/authorized_clients":
ensure => directory;
"${data_dir_path}/hs_ed25519_secret_key":
content => $real_v3_data['hs_ed25519_secret_key'];
"${data_dir_path}/hs_ed25519_public_key":
content => $real_v3_data['hs_ed25519_public_key'];
"${data_dir_path}/hostname":
content => "${real_v3_data['hostname']}\n";
}
} else {
notify {
'[tor] *** DEPRECATION WARNING***: onionV2 will soon be deprecated in Tor and in this module. You should upgrade to onionV3 as soon as possible.':
}
if $private_key {
$os_hostname = tor::onion_address($private_key.unwrap)
$real_private_key = $private_key
} else {
$tmp = tor::generate_onion_key($private_key_store_path,$private_key_name)
$os_hostname = $tmp[0]
$real_private_key = $tmp[1]
}
file{
default:
owner => $tor::daemon::params::user,
group => $tor::daemon::params::group,
mode => '0600',
notify => Service['tor'];
"${data_dir_path}/private_key":
content => $real_private_key;
"${data_dir_path}/hostname":
content => "${os_hostname}.onion\n";
}
}
} else {
File[$data_dir_path]{
ensure => absent
}
}
}
}
......
{
"name": "smash-tor",
"version": "2.2.0",
"version": "2.3.0",
"author": "SMASH",
"summary": "Installs, configures and manages Tor",
"license": "GPL-3.0",
......
require File.expand_path(File.join(File.dirname(__FILE__),'../spec_helper'))
describe 'tor::daemon::automap_hosts_on_resolve', :type => 'define' do
let(:default_facts) {
{
:osfamily => 'Debian',
:operatingsystem => 'Debian',
}
}
let(:title){ 'test_os' }
let(:facts){ default_facts }
let(:pre_condition){'Exec{path => "/bin"}
include ::tor' }
context 'with standard' do
it { is_expected.to compile.with_all_deps }
end
context 'with set to false' do
let(:params){
{
:automap_hosts_on_resolve => false,
}
}
it { is_expected.to compile.with_all_deps }
end
end
......@@ -35,6 +35,7 @@ describe 'tor::daemon::onion_service', :type => 'define' do
:order => '05',
:target => '/etc/tor/torrc',
)}
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServiceVersion 2/) }
it { is_expected.to_not contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServicePort/) }
it { is_expected.to_not contain_file('/var/lib/tor/test_os') }
end
......@@ -45,18 +46,22 @@ describe 'tor::daemon::onion_service', :type => 'define' do
}
}
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServiceVersion 2/) }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServicePort 25/) }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServicePort 443 192.168.0.1:8443/) }
it { is_expected.to_not contain_file('/var/lib/tor/test_os') }
end
context 'with private_key' do
# rspec-puppet does not yet support testing with sensitive data
# See https://github.com/rodjek/rspec-puppet/milestone/8 for upcoming support
context 'with private_key', :skip => Gem.loaded_specs['rspec-puppet'].version < Gem::Version.new('2.8') do
let(:params){
{
:ports => ['80'],
:private_key => OpenSSL::PKey::RSA.generate(1024).to_s,
:private_key => RSpec::Puppet::Sensitive.new(OpenSSL::PKey::RSA.generate(1024).to_s),
}
}
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServiceVersion 2/) }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServicePort 80/) }
it { is_expected.to contain_file('/var/lib/tor/test_os').with(
:ensure => 'directory',
......@@ -82,7 +87,7 @@ describe 'tor::daemon::onion_service', :type => 'define' do
:notify => 'Service[tor]',
)}
end
context 'with private key to generate' do
context 'with v2 private key to generate' do
let(:params){
{
:ports => ['80'],
......@@ -91,6 +96,7 @@ describe 'tor::daemon::onion_service', :type => 'define' do
}
}
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServiceVersion 2/) }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServicePort 80/) }
it { is_expected.to contain_file('/var/lib/tor/test_os').with(
:ensure => 'directory',
......@@ -116,5 +122,47 @@ describe 'tor::daemon::onion_service', :type => 'define' do
:notify => 'Service[tor]',
)}
end
context 'with v3 private key to generate' do
let(:params){
{
:v3 => true,
:ports => ['80'],
:private_key_name => 'test_os',
:private_key_store_path => File.expand_path(File.join(File.dirname(__FILE__),'..','tmp')),
}
}
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServiceVersion 3/) }
it { is_expected.to contain_concat__fragment('05.onion_service.test_os').with_content(/^HiddenServicePort 80/) }
it { is_expected.to contain_file('/var/lib/tor/test_os').with(
:ensure => 'directory',
:purge => true,
:force => true,
:recurse => true,
:owner => 'toranon',
:group => 'toranon',
:mode => '0600',
:require => 'Package[tor]',
)}
it { is_expected.to contain_file('/var/lib/tor/test_os/hostname').with(
:content => /^[a-z2-7]{56}\.onion\n/,
:owner => 'toranon',
:group => 'toranon',
:mode => '0600',
:notify => 'Service[tor]',
)}
it { is_expected.to contain_file('/var/lib/tor/test_os/hs_ed25519_secret_key').with(
:owner => 'toranon',
:group => 'toranon',
:mode => '0600',
:notify => 'Service[tor]',
)}
it { is_expected.to contain_file('/var/lib/tor/test_os/hs_ed25519_public_key').with(
:owner => 'toranon',
:group => 'toranon',
:mode => '0600',
:notify => 'Service[tor]',
)}
end
end
end
......@@ -51,23 +51,30 @@ znq+qT/KbJlwy/27X/auCAzD5rJ9VVzyWiu8nnwICS8=
expect(return_value.size).to be(2)
end
it 'creates and stores the key' do
expect(return_value.last).to be_eql(File.read(File.join(@tmp_path,'test.key')))
expect(return_value.last.unwrap).to be_eql(File.read(File.join(@tmp_path,'test.key')))
end
it 'returns a proper onion address' do
expect(return_value.first).to be_eql(call_function('tor::onion_address', File.read(File.join(@tmp_path,'test.key'))))
end
it 'does not recreate a key once created' do
expect(call_function('tor::generate_onion_key', @tmp_path, 'test')).to be_eql(call_function('tor::generate_onion_key', @tmp_path, 'test'))
res1 = unwrap_all(call_function('tor::generate_onion_key', @tmp_path, 'test'))
res2 = unwrap_all(call_function('tor::generate_onion_key', @tmp_path, 'test'))
expect(res1).to be_eql(res2)
end
it 'creates to different keys for different names' do
expect(call_function('tor::generate_onion_key', @tmp_path, 'test').first).to_not be_eql(call_function('tor::generate_onion_key', @tmp_path, 'test2'))
res1 = unwrap_all(call_function('tor::generate_onion_key', @tmp_path, 'test'))
res2 = unwrap_all(call_function('tor::generate_onion_key', @tmp_path, 'test2'))
expect(res1).not_to be_eql(res2)
end
end
context 'with an existing key' do
before(:all) do
File.open(File.join(@tmp_path,'test3.key'),'w'){|f| f << @drpsyff5srkctr7h_str }
end
it { is_expected.to run.with_params(@tmp_path,'test3').and_return(['drpsyff5srkctr7h',@drpsyff5srkctr7h_str]) }
it 'uses the existing key' do
res = unwrap_all(call_function('tor::generate_onion_key', @tmp_path,'test3'))
expect(res).to be_eql(['drpsyff5srkctr7h', @drpsyff5srkctr7h_str])
end
end
end
end
require 'spec_helper'
require 'fileutils'
describe 'tor::onionv3_key' do
before(:all) do
@tmp_path = File.expand_path(File.join(File.dirname(__FILE__),'..','fixtures','tmp'))
end
describe 'signature validation' do
it { is_expected.not_to eq(nil) }
it { is_expected.to run.with_params().and_raise_error(ArgumentError, /expects 2 arguments/) }
it { is_expected.to run.with_params(1).and_raise_error(ArgumentError, /expects 2 arguments/) }
end
describe 'normal operation' do
before(:all) do
FileUtils.rm_rf(@tmp_path) if File.exists?(@tmp_path)
FileUtils.mkdir_p(@tmp_path)
end
after(:all) do
FileUtils.rm_rf(@tmp_path) if File.exists?(@tmp_path)
end
let(:return_value) {
unwrap_all(call_function('tor::onionv3_key',@tmp_path,'test'))
}
context 'without an existing key' do
it 'returns an onion address, public and a secret key' do
expect(return_value.size).to be(3)
end
['hs_ed25519_secret_key','hs_ed25519_public_key','hostname'].each do |f|
it "creates and stores the #{f}" do
expect(return_value[f]).to be_eql(File.read(File.join(@tmp_path,'test',f)).chomp)
end
end
it 'does not recreate a key once created' do
res1 = unwrap_all(call_function('tor::onionv3_key',@tmp_path,'test2'))
res2 = unwrap_all(call_function('tor::onionv3_key',@tmp_path,'test2'))
expect(res1).to be_eql(res2)
end
it 'creates to different keys for different names' do
res1 = unwrap_all(call_function('tor::onionv3_key',@tmp_path,'test3'))
res2 = unwrap_all(call_function('tor::onionv3_key',@tmp_path,'test4'))
expect(res1).not_to be_eql(res2)
end
end
end
end
......@@ -44,3 +44,18 @@ RSpec::Mocks::Syntax.enable_expect(RSpec::Puppet::ManifestMatchers)
# Helper class to test handling of arguments which are derived from string
class AlsoString < String
end
def unwrap_all(v)
if v.respond_to?(:unwrap)
v.unwrap
elsif v.is_a?(Array)
v.map{|s| unwrap_all(s) }
elsif v.is_a?(Hash)
v.inject({}) do |res,(a,b)|
res[a] = unwrap_all(b)
res
end
else
v
end
end
......@@ -6,18 +6,12 @@ SOCKSPort 0
<% } -%>
# hidden service <%= $name %>
HiddenServiceDir <%= $data_dir_path %>
<% if $ports { -%>
<% $ports.each |$item| { -%>
HiddenServicePort <%= $item %>
<% } -%>
<% } -%>
<% if $v3 { -%>
# hidden service v3 static
HiddenServiceDir <%= $data_dir_path %>3
HiddenServiceVersion 3
<% } else { -%>
HiddenServiceVersion 2
<% } -%>
<% $ports.each |$item| { -%>
HiddenServicePort <%= $item %>
<% } -%>
<% } -%>
# AutomapHostsOnResolve
<% if $automap_hosts_on_resolve { -%>
AutomapHostsOnResolve 1
<% } -%>