Skip to content

Commit c82c095

Browse files
committed
Verify payloads using public ssh keys
0 parents  commit c82c095

File tree

133 files changed

+11940
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

133 files changed

+11940
-0
lines changed

.github/workflows/ci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Test
2+
3+
on: [push]
4+
5+
permissions:
6+
contents: read
7+
8+
jobs:
9+
build:
10+
strategy:
11+
matrix:
12+
php_version: ['8.0', '8.1', '8.2', '8.3', '8.4']
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- uses: actions/checkout@v4
17+
- name: Setup PHP
18+
uses: shivammathur/setup-php@v2
19+
with:
20+
php-version: ${{ matrix.php_version }}
21+
- name: Get composer cache directory
22+
id: composer-cache
23+
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
24+
- name: Cache dependencies
25+
uses: actions/cache@v4
26+
with:
27+
path: ${{ steps.composer-cache.outputs.dir }}
28+
key: ${{ runner.os }}-composer-${{ matrix.php_version }}-${{ hashFiles('**/composer.lock') }}
29+
restore-keys: ${{ runner.os }}-composer-${{ matrix.php_version }}-
30+
31+
- name: Install dependencies
32+
run: composer install
33+
- name: PHPStan
34+
run: vendor/bin/phpstan
35+
- name: PHPUnit
36+
run: vendor/bin/phpunit

.github/workflows/release.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Create release
2+
3+
on:
4+
push:
5+
tags:
6+
- '*.*.*'
7+
8+
permissions:
9+
contents: write
10+
packages: read
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-tags: 'true'
20+
fetch-depth: '0'
21+
22+
- name: Setup PHP
23+
uses: shivammathur/setup-php@v2
24+
with:
25+
php-version: 8.0
26+
27+
- name: Build phar
28+
run:
29+
make -C cli
30+
mv cli/ssh-verify.phar bin/ssh-verify
31+
32+
- name: Release
33+
uses: softprops/action-gh-release@v2
34+
with:
35+
files: |
36+
bin/ssh-verify
37+
bin/ssh-sign
38+
bin/pem-to-openssh

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.phpunit.cache

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2014 WonderNetwork
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Readme.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Verify message payloads using publicly available ssh keys
2+
3+
Most of the servers have a public/private ssh key pair available,
4+
so do most of the developers. Machines have their public parts
5+
available on port 22 (via `ssh-keyscan`) and devs upload theirs
6+
to Github and similar places. How about instead of authorizing
7+
our messages using passwords and tokens, we could just sign them
8+
using rsa/ecdsa/ed25519 keys we already use for SSH communication,
9+
and let the receiver verify that against publicly available records?
10+
11+
## How it works?
12+
13+
The sender identifies a private key they want to use. This might
14+
be an `/etc/ssh/ssh_host_ecdsa_key`, for which the public counterpart
15+
is available via `ssh-keyscan` to the receiver. For people, their
16+
personal ssh key uploaded to github could be used, as the public
17+
keys of each user are available at `github.com/username.keys`.
18+
19+
> [!NOTE]
20+
> Most of the servers exposing something over HTTP also have
21+
> a HTTPS certificate issued. While this is not strictly in scope
22+
> of this package because that is not a SSH private key, it can
23+
> be easily converted to one, and since the cert is exposed on
24+
> port 443, it is easy to verify by the receivers.
25+
26+
`ssh-keygen` can be used to sign a message (see also
27+
[`bin/ssh-sign` script][ssh-sign]). Then they can send the payload
28+
with a corresponding `.sig` file to the receiver.
29+
30+
```sh
31+
ssh-keygen -Y sign -f ~/.ssh/id_rsa -n "com.acme.namespace" payload.json
32+
curl --silent \
33+
--form "[email protected]" \
34+
--form "[email protected]" \
35+
--form "username=mlebkowski" \
36+
https://example.org
37+
```
38+
39+
The receiver in turn needs to determine the sender’s identity.
40+
This could be done explicitly (e.g. by passing their github username
41+
along with the message), or implicitly: by the sender’s IP address.
42+
43+
Then the receiver confirms that the signature used one of the public
44+
keys available for a given sender, and that the signature matches
45+
the message payload. No passwords.
46+
47+
See also [example][demo] for a simple proof of concept.
48+
49+
## Installation
50+
51+
```sh
52+
composer require wondernetwork/ssh-pubkey-payload-verification
53+
```
54+
55+
You will need a `psr/http-client` and `psr/simple-cache` implementations.
56+
Most frameworks will have this for you, but in case you don’t have them,
57+
you can just pick one from the top:
58+
59+
* [packagist `psr/http-client` implementations][http-client]
60+
* [packagist `psr/http-factory` implementations][http-factory]
61+
* [packagist `psr/http-message` implementations][http-message]
62+
* [packagist `psr/simple-cache` implementations][simple-cache]
63+
64+
## Usage
65+
66+
```php
67+
use WonderNetwork\SshPubkeyPayloadVerification\ValidatorBuilder;
68+
69+
// simples use case:
70+
$validator = ValidatorBuilder::start()->build();
71+
72+
// all available configuration options:
73+
$validator = ValidatorBuilder::start()
74+
// cache the fetched ssh-keyscans
75+
->withCache($simpleCacheAdapter)
76+
77+
// provide your own httpClient instead of relying on autodiscovery
78+
// if you’d like to cache calls to github, pass your own caching client
79+
->withHttpClient($httpClient)
80+
->withHttpMessageFactory($requestFactory)
81+
82+
// when a request comes in, just execute ssh-keyscan
83+
// to get all their public keys. This is the default
84+
->useRealtimeSshKeyscan()
85+
// instead of doing keyscan for each sender
86+
// pass a pre-determined known-hosts contents or filename
87+
->withKnownHosts($knownHostsContent)
88+
->withKnownHostsFile($knownHostsFilename)
89+
// replace the whole host keyscan implementation with your own
90+
->withSshKeyscan($myFancyKeyscan)
91+
92+
// replace the `KeyRepository` entirely and provide you own
93+
// way for getting list of keys of any given sender
94+
// this allows you to create custom sender types and sources
95+
// of their public keys
96+
->withCustomKeyRepository()
97+
->build();
98+
```
99+
100+
Having the validator, we can now proceed to checking payloads:
101+
102+
```php
103+
use WonderNetwork\SshPubkeyPayloadVerification\Validator;
104+
use WonderNetwork\SshPubkeyPayloadVerification\ValidatorException;
105+
106+
/** @var Validator $validator */
107+
try {
108+
$validator->validate(
109+
sender: sprintf("ssh://%s:%d", $_SERVER['REMOTE_ADDR'], 22),
110+
// alternatives:
111+
// sender: sprintf("https://%s:%d", $_SERVER['REMOTE_ADDR'], 443),
112+
// sender: "github://mlebkowski",
113+
namespace: "something you just need to agree on",
114+
// deliver it any way you like it
115+
message: $_POST['message'],
116+
// deliver it any way you like it:
117+
signature: $_POST['signature'],
118+
);
119+
// good, we can act as if this sender/message are authenticated!
120+
} catch (ValidatorException $e) {
121+
// bad cookie, we discard the message
122+
// depending on $e, there might be some interesting context why it failed
123+
}
124+
```
125+
126+
## Security
127+
128+
This solution is based on the same PKI infrastructure and
129+
mathematics behind RSA/ECC as the connection to your bank does.
130+
There are some caveats:
131+
132+
* The `ssh-keyscan` is over an insecure connection. There are no
133+
equivalent of HTTPS certificates for SSH connections, so an
134+
attacker in position to alter your network traffic is able to
135+
spoof this. Similarly, if the target sender is taken over before
136+
the receiver had the chance to receive their public key list.
137+
138+
Consider using a static known hosts file instead, or think about
139+
implementing a solution that uses HTTPS certificates if that’s
140+
something your senders have at hand.
141+
142+
* This does not server as _authorization_ of the sender’s message,
143+
so you need to do this separately. Nor this secures the
144+
communication in any way, so think about transport layer security
145+
separately (delivering over HTTPS should be enough). This is
146+
stateless, so it doesn’t prevent replay attacks in any way.
147+
148+
* There is no revocation mechanism other than manually evicting
149+
a key from your cache.
150+
151+
## Cookbook
152+
153+
### Using HTTPS certificates
154+
155+
In order to use a certificate, you need to first convert it’s PEM private
156+
key into a SSH private key.
157+
158+
```sh
159+
# copy from the place you keep HTTPS certificates
160+
cp /etc/letsencrypt/live/acme.example.org/privkey.pem ssh-signkey.rsa
161+
# limit permissions, or ssh-keyscan will refuse to work with that file
162+
chmod 0600 ssh-signkey.rsa
163+
# rewrite the key in-place (here: without using a passphrase) in OpenSSH format
164+
ssh-keygen -p -N "" -f ssh-signkey.rsa
165+
# use as any other SSH private key
166+
jq -Mcn .valid=true | ssh-keygen -Y sign -n example -f ssh-signkey.rsa
167+
```
168+
169+
[ssh-sign]: bin/ssh-sign
170+
[demo]: ./example/
171+
[http-client]: https://packagist.org/providers/psr/http-client-implementation
172+
[simple-cache]: https://packagist.org/providers/psr/simple-cache-implementation
173+
[http-factory]: https://packagist.org/providers/psr/http-factory-implementation
174+
[http-message]: https://packagist.org/providers/psr/http-message-implementation

bin/pem-to-openssh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
usage() {
6+
{
7+
echo "usage: $0 <source> <target> [<passphrase>]"
8+
echo ""
9+
echo " source: private key in PEM format (used in HTTPS certificates)"
10+
echo " target: where to save your new OpenSSH signing key"
11+
echo " passphrase: encrypt the output key using a passphrase"
12+
echo " use an empty string or '-' to be asked interactively"
13+
echo " omit this argument to skip encryption"
14+
} >&2
15+
return 1
16+
}
17+
18+
main() {
19+
declare source="${1:-}" target="${2:-}" passphrase="${3:-}"
20+
21+
if [[ -z "$source" ]] || [[ -z "$target" ]] || [[ $# -gt 3 ]]; then
22+
usage
23+
fi
24+
25+
if [[ ! -r "$source" ]]; then
26+
echo "Error: source file $source is not readable" >&2
27+
return 2
28+
fi
29+
30+
if [[ -f "$target" ]]; then
31+
read -rp "Target file $target exists. Overwrite? Press Ctrl+C to cancel, Enter to continue: " >&2
32+
fi
33+
34+
cp "$source" "$target"
35+
chmod 0600 "$target"
36+
37+
if [[ $# -eq 3 ]] && [[ "-" == "$passphrase" || "" == "$passphrase" ]]; then
38+
ssh-keygen -p -f "$target"
39+
else
40+
ssh-keygen -p -N "$passphrase" -f "$target"
41+
fi
42+
}
43+
44+
main "$@"

bin/ssh-sign

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
usage() {
6+
{
7+
echo "usage: $0 <namespace> <ssh-key> [<message-file>...]"
8+
echo ""
9+
echo " namespace: is an arbitrary string you need to agree on with the recipient"
10+
echo " ssh-key: will be used to sign the payload."
11+
echo " message-file: one or more files to sign. /dev/stdin is used by default"
12+
echo " signature will be saved with a file suffixed .sig"
13+
echo " if a process substitution or stdin will be provided"
14+
echo " then the signature will be written to stdout"
15+
} >&2
16+
return 1
17+
}
18+
19+
main() {
20+
declare namespace="${1:-}" ssh_key="${2:-}"
21+
shift 2 || :
22+
23+
if [[ -z "$namespace" ]]; then
24+
echo "Error: namespace argument is required" >&2
25+
usage
26+
fi
27+
28+
if [[ -z "$ssh_key" ]]; then
29+
echo "Error: ssh-key argument is required" >&2
30+
usage
31+
fi
32+
33+
if [[ ! -r "$ssh_key" ]]; then
34+
echo "Key $ssh_key does not exist or is not readable" >&2
35+
return 1
36+
fi
37+
38+
if [[ -z "$*" ]]; then
39+
ssh-keygen -Y sign -n "$namespace" -f "$ssh_key"
40+
fi
41+
42+
for file in "$@"; do
43+
if [[ -f "$file" ]]; then
44+
ssh-keygen -Y sign -n "$namespace" -f "$ssh_key" "$file"
45+
else
46+
ssh-keygen -Y sign -n "$namespace" -f "$ssh_key" < "$file"
47+
fi
48+
done
49+
}
50+
51+
main "$@"

cli/Makefile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.DEFAULT_TARGET := ../bin/ssh-verify
2+
3+
# we need to build outside of the root composer path
4+
BUILD_PATH := $(shell mktemp -d)
5+
6+
../bin/ssh-verify: tools/box ${BUILD_PATH}/vendor
7+
@echo "${BUILD_PATH}"
8+
@tools/box/vendor/bin/box compile -d "${BUILD_PATH}"
9+
@mv "${BUILD_PATH}/index.phar" ../bin/ssh-verify
10+
11+
${BUILD_PATH}/vendor:
12+
@cp composer.json composer.lock "${BUILD_PATH}"
13+
@sed s/@git-version@/$(shell git describe --tags --always HEAD)/ ssh-verify > "${BUILD_PATH}/index.php"
14+
@composer install -d "${BUILD_PATH}"
15+
@unlink ${BUILD_PATH}/vendor/wondernetwork/ssh-pubkey-payload-verification
16+
@mkdir ${BUILD_PATH}/vendor/wondernetwork/ssh-pubkey-payload-verification
17+
@cp -r ../src ../composer.json ${BUILD_PATH}/vendor/wondernetwork/ssh-pubkey-payload-verification/
18+
19+
tools/box: tools/box/vendor
20+
tools/box/vendor: tools/box/composer.lock tools/box/composer.json
21+
@composer --working-dir tools/box install
22+
23+
PHONY: clean
24+
clean:
25+
@rm -rvf ../bin/ssh-verify tools/box/vendor
26+

0 commit comments

Comments
 (0)