├── .chglog
├── CHANGELOG.tpl.md
└── config.yml
├── .github
├── FUNDING.yml
└── workflows
│ ├── changelog.yml
│ ├── erlang.yml
│ └── git.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── PROTOCOL.sshsig.txt
├── README.md
├── rebar.config
├── rebar.lock
├── src
├── ssh_signature.app.src
└── ssh_signature.erl
└── test
└── ssh_signature_SUITE.erl
/.chglog/CHANGELOG.tpl.md:
--------------------------------------------------------------------------------
1 | {{ if .Versions -}}
2 |
3 | ## [Unreleased]
4 |
5 | {{ if .Unreleased.CommitGroups -}}
6 | {{ range .Unreleased.CommitGroups -}}
7 | ### {{ .Title }}
8 | {{ range .Commits -}}
9 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
10 | {{ end }}
11 | {{ end -}}
12 | {{ end -}}
13 | {{ end -}}
14 |
15 | {{ range .Versions }}
16 |
17 | ## {{ if .Tag.Previous }}[{{ .Tag.Name }}]{{ else }}{{ .Tag.Name }}{{ end }} - {{ datetime "2006-01-02" .Tag.Date }}
18 | {{ range .CommitGroups -}}
19 | ### {{ .Title }}
20 | {{ range .Commits -}}
21 | - {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} ([`{{ .Hash.Short }}`]({{ $.Info.RepositoryURL }}/commit/{{ .Hash.Long }}))
22 | {{ end }}
23 | {{ end -}}
24 |
25 | {{- if .NoteGroups -}}
26 | {{ range .NoteGroups -}}
27 | ### {{ .Title }}
28 | {{ range .Notes }}
29 | {{ .Body }}
30 | {{ end }}
31 | {{ end -}}
32 | {{ end -}}
33 | {{ end -}}
34 |
35 | {{- if .Versions }}
36 | [Unreleased]: {{ .Info.RepositoryURL }}/compare/{{ $latest := index .Versions 0 }}{{ $latest.Tag.Name }}...HEAD
37 | {{ range .Versions -}}
38 | {{ if .Tag.Previous -}}
39 | [{{ .Tag.Name }}]: {{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }}
40 | {{ end -}}
41 | {{ end -}}
42 | {{ end -}}
43 |
--------------------------------------------------------------------------------
/.chglog/config.yml:
--------------------------------------------------------------------------------
1 | style: github
2 | template: CHANGELOG.tpl.md
3 | info:
4 | title: CHANGELOG
5 | repository_url: https://github.com/hauleth/ssh_signature
6 | options:
7 | commits:
8 | filters:
9 | Type:
10 | - ft
11 | - fix
12 | - docs
13 | commit_groups:
14 | title_maps:
15 | ft: Features
16 | fix: Bug Fixes
17 | docs: Documentation
18 | header:
19 | pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?!?\\:\\s(.*)$"
20 | pattern_maps:
21 | - Type
22 | - Scope
23 | - Subject
24 | notes:
25 | keywords:
26 | - BREAKING CHANGE
27 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [hauleth]
4 |
--------------------------------------------------------------------------------
/.github/workflows/changelog.yml:
--------------------------------------------------------------------------------
1 | name: Changelog
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | update-changelog:
9 | name: Update changelog
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | with:
14 | fetch-depth: 0
15 | ref: master
16 | - uses: Bpazy/setup-git-chglog@v1
17 | - name: git-chglog version
18 | run: git chglog --version
19 | - name: Generate changelog
20 | run: git chglog | tee CHANGELOG.md
21 | - name: Commit
22 | run: |
23 | git config user.email ""
24 | git config user.name "GitHub Action Bot"
25 | git diff -- CHANGELOG.md
26 | git commit -m "chore: update CHANGELOG [skip ci]" CHANGELOG.md && git push origin master || true
27 |
--------------------------------------------------------------------------------
/.github/workflows/erlang.yml:
--------------------------------------------------------------------------------
1 | name: Erlang CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | fail-fast: false
10 | matrix:
11 | otp_version: ['22', '23', '24', '25']
12 | steps:
13 | - name: Install OpenSSH
14 | run: sudo apt install openssh-client
15 | - uses: actions/checkout@v1
16 | - name: Set up Erlang
17 | uses: erlef/setup-beam@v1
18 | with:
19 | otp-version: ${{ matrix.otp_version }}
20 | rebar3-version: '3.16'
21 | - name: Compile
22 | run: rebar3 compile
23 | - name: Run tests
24 | run: rebar3 do ct --cover
25 | env:
26 | SHELL: /bin/sh
27 | - name: Check Dialyzer results
28 | run: rebar3 dialyzer
29 | - name: Generate coverage data
30 | run: rebar3 covertool generate
31 | - uses: codecov/codecov-action@v1
32 | with:
33 | file: ./_build/test/covertool/ssh_signature.covertool.xml
34 | flags: otp-${{ matrix.otp_version }}
35 |
36 | docs:
37 | runs-on: ubuntu-latest
38 | steps:
39 | - uses: actions/checkout@v1
40 | - name: Set up Erlang
41 | uses: erlef/setup-beam@v1
42 | with:
43 | otp-version: '25'
44 | rebar3-version: '3.16'
45 | - name: Check if docs build
46 | run: rebar3 ex_doc
47 |
--------------------------------------------------------------------------------
/.github/workflows/git.yml:
--------------------------------------------------------------------------------
1 | name: Git
2 |
3 | on:
4 | pull_request_target:
5 | branches: [ master ]
6 |
7 | jobs:
8 | commit-messages:
9 | name: Check commit messages
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | with:
14 | fetch-depth: 0
15 | ref: ${{ github.event.pull_request.head.sha }}
16 | - name: Get PR URL
17 | uses: Dovyski/payload-info-action@master
18 | id: issue_url
19 | with:
20 | filter_pull_request: '.pull_request._links.issue.href'
21 | - name: Check
22 | env:
23 | TYPES: ft|fix|docs|chore|test
24 | run: |
25 | ! git log --oneline -E --invert-grep --grep="^($TYPES)(\([^)]\))?:" --pretty=format:"::error title=Invalid commit message::%h %s" origin/$GITHUB_BASE_REF... | grep "."
26 | - name: Unlabel correct PR
27 | if: ${{ success() }}
28 | run: |
29 | curl -X DELETE \
30 | --header 'authorization: Bearer ${{ github.token }}' \
31 | --header 'content-type: application/json' \
32 | ${{ steps.issue_url.outputs.value }}/labels/invalid/commit-messages
33 | - name: Label PR with invalid commit messages
34 | if: ${{ failure() }}
35 | run: |
36 | curl -X POST \
37 | --header 'authorization: Bearer ${{ github.token }}' \
38 | --header 'content-type: application/json' \
39 | --data '["invalid/commit-messages"]' \
40 | ${{ steps.issue_url.outputs.value }}/labels
41 |
42 | # labeler:
43 | # name: Label PRs depending on changes
44 | # runs-on: ubuntu-latest
45 | # steps:
46 | # - uses: actions/checkout@v2
47 | # with:
48 | # fetch-depth: 0
49 | # ref: ${{ github.event.pull_request.head.sha }}
50 | # - uses: actions/labeler@v3
51 | # with:
52 | # repo-token: "${{ github.token }}"
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .rebar3
2 | _*
3 | .eunit
4 | *.o
5 | *.beam
6 | *.plt
7 | *.swp
8 | *.swo
9 | .erlang.cookie
10 | ebin
11 | log
12 | erl_crash.dump
13 | .rebar
14 | logs
15 | _build
16 | .idea
17 | *.iml
18 | rebar3.crashdump
19 | *~
20 | /doc
21 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ## [Unreleased]
3 |
4 |
5 |
6 | ## v0.1.0 - 2022-08-20
7 | ### Bug Fixes
8 | - conditionaly use removed function ([`784871a`](https://github.com/hauleth/ssh_signature/commit/784871a584db40a6a2e954b5d6093babecf0a28e))
9 | - install OpenSSL GitHub Actions ([`09dd972`](https://github.com/hauleth/ssh_signature/commit/09dd9727b849c5cb90f411041f377a80839a0d40))
10 |
11 | ### Documentation
12 | - add protocol description to the docs ([`efd03d2`](https://github.com/hauleth/ssh_signature/commit/efd03d279772c9ff159748cedb33c9c780e30ba5))
13 | - add example in README ([`b694f60`](https://github.com/hauleth/ssh_signature/commit/b694f6004b7e64a0ded835a59098e5e9e8b3c04b))
14 |
15 |
16 | [Unreleased]: https://github.com/hauleth/ssh_signature/compare/v0.1.0...HEAD
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | Copyright 2022, Łukasz Niemier .
179 |
180 | Licensed under the Apache License, Version 2.0 (the "License");
181 | you may not use this file except in compliance with the License.
182 | You may obtain a copy of the License at
183 |
184 | http://www.apache.org/licenses/LICENSE-2.0
185 |
186 | Unless required by applicable law or agreed to in writing, software
187 | distributed under the License is distributed on an "AS IS" BASIS,
188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189 | See the License for the specific language governing permissions and
190 | limitations under the License.
191 |
192 |
--------------------------------------------------------------------------------
/PROTOCOL.sshsig.txt:
--------------------------------------------------------------------------------
1 | This document describes a lightweight SSH Signature format
2 | that is compatible with SSH keys and wire formats.
3 |
4 | At present, only detached and armored signatures are supported.
5 |
6 | 1. Armored format
7 |
8 | The Armored SSH signatures consist of a header, a base64
9 | encoded blob, and a footer.
10 |
11 | The header is the string "-----BEGIN SSH SIGNATURE-----"
12 | followed by a newline. The footer is the string
13 | "-----END SSH SIGNATURE-----" immediately after a newline.
14 |
15 | The header MUST be present at the start of every signature.
16 | Files containing the signature MUST start with the header.
17 | Likewise, the footer MUST be present at the end of every
18 | signature.
19 |
20 | The base64 encoded blob SHOULD be broken up by newlines
21 | every 76 characters.
22 |
23 | Example:
24 |
25 | -----BEGIN SSH SIGNATURE-----
26 | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgJKxoLBJBivUPNTUJUSslQTt2hD
27 | jozKvHarKeN8uYFqgAAAADZm9vAAAAAAAAAFMAAAALc3NoLWVkMjU1MTkAAABAKNC4IEbt
28 | Tq0Fb56xhtuE1/lK9H9RZJfON4o6hE9R4ZGFX98gy0+fFJ/1d2/RxnZky0Y7GojwrZkrHT
29 | FgCqVWAQ==
30 | -----END SSH SIGNATURE-----
31 |
32 | 2. Blob format
33 |
34 | #define MAGIC_PREAMBLE "SSHSIG"
35 | #define SIG_VERSION 0x01
36 |
37 | byte[6] MAGIC_PREAMBLE
38 | uint32 SIG_VERSION
39 | string publickey
40 | string namespace
41 | string reserved
42 | string hash_algorithm
43 | string signature
44 |
45 | The publickey field MUST contain the serialisation of the
46 | public key used to make the signature using the usual SSH
47 | encoding rules, i.e RFC4253, RFC5656,
48 | draft-ietf-curdle-ssh-ed25519-ed448, etc.
49 |
50 | Verifiers MUST reject signatures with versions greater than those
51 | they support.
52 |
53 | The purpose of the namespace value is to specify a unambiguous
54 | interpretation domain for the signature, e.g. file signing.
55 | This prevents cross-protocol attacks caused by signatures
56 | intended for one intended domain being accepted in another.
57 | The namespace value MUST NOT be the empty string.
58 |
59 | The reserved value is present to encode future information
60 | (e.g. tags) into the signature. Implementations should ignore
61 | the reserved field if it is not empty.
62 |
63 | Data to be signed is first hashed with the specified hash_algorithm.
64 | This is done to limit the amount of data presented to the signature
65 | operation, which may be of concern if the signing key is held in limited
66 | or slow hardware or on a remote ssh-agent. The supported hash algorithms
67 | are "sha256" and "sha512".
68 |
69 | The signature itself is made using the SSH signature algorithm and
70 | encoding rules for the chosen key type. For RSA signatures, the
71 | signature algorithm must be "rsa-sha2-512" or "rsa-sha2-256" (i.e.
72 | not the legacy RSA-SHA1 "ssh-rsa").
73 |
74 | This blob is encoded as a string using the RFC4253 encoding
75 | rules and base64 encoded to form the middle part of the
76 | armored signature.
77 |
78 |
79 | 3. Signed Data, of which the signature goes into the blob above
80 |
81 | #define MAGIC_PREAMBLE "SSHSIG"
82 |
83 | byte[6] MAGIC_PREAMBLE
84 | string namespace
85 | string reserved
86 | string hash_algorithm
87 | string H(message)
88 |
89 | The preamble is the six-byte sequence "SSHSIG". It is included to
90 | ensure that manual signatures can never be confused with any message
91 | signed during SSH user or host authentication.
92 |
93 | The reserved value is present to encode future information
94 | (e.g. tags) into the signature. Implementations should ignore
95 | the reserved field if it is not empty.
96 |
97 | The data is concatenated and passed to the SSH signing
98 | function.
99 |
100 | $OpenBSD: PROTOCOL.sshsig,v 1.4 2020/08/31 00:17:41 djm Exp $
101 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | SSH signatures for Erlang
2 | =====
3 |
4 | Implementation of [SSH signatures][ssh-keygen-sign] in Erlang. It uses only
5 | stuff distributed with OTP, so no external dependencies needed.
6 |
7 | [ssh-keygen-sign]: https://man.openbsd.org/ssh-keygen#Y~4
8 |
9 | Currently supported algorithms:
10 |
11 | - RSA
12 | - Ed25519
13 | - Ed448 - not tested, as my implementation of OpenSSH do not support Ed448 keys
14 |
15 | ## Usage
16 |
17 | There are just 3 exported functions:
18 |
19 | - `sign/{3,4}` which allows signing data
20 | - `verify/2` that verifies the signature for given data and outputs details
21 | about signature
22 |
23 | ```erlang
24 | % First we need the private key that we will use for signing.
25 | % For the purpose of this example just use RSA-4096
26 | SecretKey = public_key:generate_key({rsa, 4096, 3}),
27 |
28 | Data = <<"Foo">>,
29 |
30 | % Sign data using our key. 3rd argument there is a namespace, that must be
31 | % non-empty string.
32 | Signature = ssh_signature:sign(Data, SecretKey, "text"),
33 |
34 | % The created signature is already in armoured (ASCII-only) format.
35 |
36 | % Now we can check if the signature is correct
37 | {ok, #{public_key := PubKey, ns := <<"test">>, signature := Sig}} =
38 | ssh_signature:verify(Data, Signature).
39 | % Notice that we do not pass public key to verify/2, it is left to the user to
40 | % check whether the returned public key is trusted.
41 | ```
42 |
43 | ## License
44 |
45 | See [LICENSE](LICENSE)
46 |
--------------------------------------------------------------------------------
/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, [
2 | debug_info,
3 | warnings_as_errors
4 | ]}.
5 |
6 | {project_plugins, [
7 | rebar3_ex_doc,
8 | rebar3_lint,
9 | covertool,
10 | erlfmt
11 | ]}.
12 |
13 | {deps, []}.
14 |
15 | {ct_opts, [{create_priv_dir, manual_per_tc}]}.
16 |
17 | {profiles, [
18 | {test, [
19 | {deps, [erlexec]},
20 | {erl_opts, [nowarn_export_all]}
21 | ]}
22 | ]}.
23 |
24 | {ex_doc, [
25 | {extras, [
26 | <<"README.md">>,
27 | <<"LICENSE">>,
28 | <<"CHANGELOG.md">>,
29 | <<"PROTOCOL.sshsig.txt">>
30 | ]},
31 | {main, <<"readme">>}
32 | ]}.
33 |
34 | {hex, [
35 | {doc, #{provider => ex_doc}}
36 | ]}.
37 |
38 | {erlfmt, [
39 | write,
40 | {print_width, 81}
41 | ]}.
42 |
43 | {elvis, [
44 | #{
45 | dirs => ["apps/*/src", "src"],
46 | filter => "*.erl",
47 | rules => [
48 | % {elvis_style, line_length, #{
49 | % ignore => [],
50 | % limit => 81,
51 | % skip_comments => whole_line
52 | % }},
53 | % {elvis_style, no_tabs},
54 | % {elvis_style, no_trailing_whitespace},
55 | {elvis_style, macro_names, #{ignore => []}},
56 | {elvis_style, macro_module_names},
57 | {elvis_style, operator_spaces, #{
58 | rules => [
59 | {right, ","},
60 | {right, "++"},
61 | {left, "++"}
62 | ]
63 | }},
64 | {elvis_style, nesting_level, #{level => 3}},
65 | {elvis_style, god_modules, #{
66 | limit => 25,
67 | ignore => []
68 | }},
69 | {elvis_style, no_if_expression},
70 | {elvis_style, invalid_dynamic_call, #{
71 | ignore => [
72 | systemd_journal_h,
73 | systemd_kmsg_formatter
74 | ]
75 | }},
76 | {elvis_style, used_ignored_variable},
77 | {elvis_style, no_behavior_info},
78 | {
79 | elvis_style,
80 | module_naming_convention,
81 | #{
82 | regex => "^[a-z]([a-z0-9]*_?)*(_SUITE)?$",
83 | ignore => []
84 | }
85 | },
86 | {
87 | elvis_style,
88 | function_naming_convention,
89 | #{regex => "^([a-z][a-z0-9]*_?)*$"}
90 | },
91 | {elvis_style, state_record_and_type},
92 | {elvis_style, no_spec_with_records},
93 | {elvis_style, dont_repeat_yourself, #{min_complexity => 20}},
94 | {elvis_style, no_debug_call, #{ignore => []}}
95 | ]
96 | },
97 | #{
98 | dirs => ["."],
99 | filter => "rebar.config",
100 | rules => [
101 | {elvis_project, no_deps_master_rebar, #{ignore => []}},
102 | {elvis_project, protocol_for_deps_rebar, #{ignore => []}}
103 | ]
104 | }
105 | ]}.
106 |
--------------------------------------------------------------------------------
/rebar.lock:
--------------------------------------------------------------------------------
1 | [].
2 |
--------------------------------------------------------------------------------
/src/ssh_signature.app.src:
--------------------------------------------------------------------------------
1 | {application, ssh_signature, [
2 | {description, "Pure Erlang implementation of SSH signatures"},
3 | {vsn, git},
4 | {registered, []},
5 | {applications, [
6 | kernel,
7 | stdlib,
8 | public_key,
9 | ssh
10 | ]},
11 | {env, []},
12 | {modules, []},
13 |
14 | {licenses, ["Apache 2.0"]},
15 | {links, [
16 | {"GitHub", "https://github.com/hauleth/ssh_signature"},
17 | {"PROTOCOL.sshsig",
18 | "https://raw.githubusercontent.com/openssh/openssh-portable/6851f4b8c3fc1b3e1114c56106e4dc31369c8513/PROTOCOL.sshsig"}
19 | ]}
20 | ]}.
21 |
--------------------------------------------------------------------------------
/src/ssh_signature.erl:
--------------------------------------------------------------------------------
1 | -module(ssh_signature).
2 |
3 | -dialyzer([
4 | {no_improper_lists, split/1},
5 | % Needed due to fact that OTP 25 removed `{ed_pri, _, _, _}' key format
6 | {no_match, [
7 | ed_to_pub/1,
8 | pk_sign/3,
9 | pk_verify/4,
10 | priv_to_public/1,
11 | sig_type/2,
12 | type_sig/3
13 | ]},
14 | {nowarn_function, [pk_sign/3, pk_verify/4]}
15 | ]).
16 |
17 | -include_lib("public_key/include/public_key.hrl").
18 |
19 | -export([sign/3, sign/4]).
20 | -export([verify/2]).
21 |
22 | -ifdef(TEST).
23 | -export([priv_to_public/1, decode/1]).
24 | -endif.
25 |
26 | -export_type([namespace/0, hash_algorithm/0]).
27 |
28 | -define(MAGIC_PREAMBLE, "SSHSIG").
29 | -define(SIG_VERSION, 16#01).
30 | -define(UINT32(X), (X):32 / unsigned - big - integer).
31 | -define(STRING(X), ?UINT32(size(X)), (X) / binary).
32 | -define(BEGIN, "-----BEGIN SSH SIGNATURE-----").
33 | -define(END, "-----END SSH SIGNATURE-----").
34 |
35 | -type namespace() :: unicode:chardata().
36 | -type hash_algorithm() :: sha256 | sha512.
37 |
38 | %% @equiv sign(Data, Key, NS, #{})
39 | sign(Data, Key, NS) -> sign(Data, Key, NS, #{}).
40 |
41 | %% @doc Sign `Data' using SSH signature format with `Key'.
42 | %%
43 | %% The `NS' must be not empty.
44 | %%
45 | %% == Options ==
46 | %%
47 | %%
48 | %% - `hash' - hash algorithm used on input data. Can be either `sha256'
49 | %% or `sha512'. Defaults to `sha512'.
50 | %%
51 | %% @end
52 | -spec sign(iodata(), public_key:private_key(), namespace(), Opts) ->
53 | unicode:chardata()
54 | when
55 | Opts :: #{
56 | hash => hash_algorithm()
57 | }.
58 | sign(Data, Key, NS, Opts) ->
59 | NS0 = iolist_to_binary(NS),
60 | case NS0 of
61 | <<>> -> error({badarg, empty_namespace});
62 | _ -> ok
63 | end,
64 | Algo = maps:get(hash, Opts, sha512),
65 | Reserved = <<"">>,
66 | Body = body(Data, NS0, Reserved, Algo),
67 | Signature = pk_sign(Body, Algo, Key),
68 | SigType = sig_type(Key, Algo),
69 | Sig = <>,
70 | EncPub = encode(priv_to_public(Key)),
71 | Result =
72 | <>,
75 | iolist_to_binary([?BEGIN, $\n, split(base64:encode(Result)), $\n, ?END]).
76 |
77 | %% @doc Verify `Signature' of `Data'.
78 | %%
79 | %% Notice that this function do not check authenticity of the provided key. That
80 | %% is left to the user to check whether key used for signing match the
81 | %% requirements.
82 | %%
83 | %% @end
84 | -spec verify(iodata(), unicode:chardata()) -> {ok, Result} | {error, term()} when
85 | Result :: #{
86 | ns => namespace(),
87 | public_key => public_key:public_key(),
88 | signature => binary()
89 | }.
90 | verify(Data, Signature) ->
91 | Sig0 = string:trim(Signature),
92 | Sig1 = iolist_to_binary(Sig0),
93 | Size = byte_size(Sig1) - length(?BEGIN) - length(?END) - 2,
94 | case Sig1 of
95 | <> ->
96 | Sig3 = base64:decode(Sig2),
97 | case parse(Sig3) of
98 | {ok, #{
99 | ns := NS,
100 | pk := PublicKey,
101 | reserved := R,
102 | signature := {_, Sig},
103 | hash_algorithm := Algo
104 | }} ->
105 | Body = body(Data, NS, R, Algo),
106 | case pk_verify(Body, Algo, Sig, PublicKey) of
107 | true ->
108 | {ok, #{
109 | ns => NS,
110 | public_key => PublicKey,
111 | signature => Sig
112 | }};
113 | false ->
114 | {error, invalid_signature}
115 | end;
116 | {error, _} = Error ->
117 | Error
118 | end;
119 | _ ->
120 | {error, invalid_armour}
121 | end.
122 |
123 | parse(
124 | <>
128 | ) when
129 | SigDS =:= SAS + 4 + SigS + 4,
130 | (SAlgo =:= <<"sha256">> orelse SAlgo =:= <<"sha512">>)
131 | ->
132 | PubKey = decode(EncPub),
133 | Algo =
134 | case SAlgo of
135 | <<"sha256">> -> sha256;
136 | <<"sha512">> -> sha512
137 | end,
138 | case type_sig(SigAlgo, PubKey, Algo) of
139 | true ->
140 | {ok, #{
141 | version => Version,
142 | pk => PubKey,
143 | ns => NS,
144 | reserved => R,
145 | hash_algorithm => Algo,
146 | signature => {SigAlgo, Sig}
147 | }};
148 | _ ->
149 | {error, type_mismatch}
150 | end;
151 | parse(_) ->
152 | {error, invalid_format}.
153 |
154 | pk_sign(Body, _, {ed_pri, _, _, _} = Key) ->
155 | public_key:sign(Body, none, Key);
156 | pk_sign(Body, _, #'ECPrivateKey'{} = Key) ->
157 | public_key:sign(Body, none, Key);
158 | pk_sign(Body, Algo, #'RSAPrivateKey'{} = Key) ->
159 | public_key:sign(Body, Algo, Key).
160 |
161 | pk_verify(Body, _, Sig, {ed_pub, _, _} = Key) ->
162 | public_key:verify(Body, none, Sig, Key);
163 | pk_verify(Body, _, Sig, {#'ECPoint'{}, _} = Key) ->
164 | public_key:verify(Body, none, Sig, Key);
165 | pk_verify(Body, Algo, Sig, #'RSAPublicKey'{} = Key) ->
166 | public_key:verify(Body, Algo, Sig, Key).
167 |
168 | body(Data, NS, R, Algo) ->
169 | H = crypto:hash(Algo, Data),
170 | <>.
172 |
173 | sig_type({ed_pri, Type, _Pub, _Pri}, _Algo) ->
174 | <<"ssh-", (atom_to_binary(Type, utf8))/binary>>;
175 | sig_type(#'ECPrivateKey'{parameters = {namedCurve, ?'id-Ed25519'}}, _) ->
176 | <<"ssh-ed25519">>;
177 | sig_type(#'ECPrivateKey'{parameters = {namedCurve, ?'id-Ed448'}}, _) ->
178 | <<"ssh-ed448">>;
179 | sig_type(#'RSAPrivateKey'{}, sha256) ->
180 | <<"rsa-sha2-256">>;
181 | sig_type(#'RSAPrivateKey'{}, sha512) ->
182 | <<"rsa-sha2-512">>.
183 |
184 | type_sig(<<"ssh-ed25519">>, {ed_pub, ed25519, _}, _) -> true;
185 | type_sig(<<"ssh-ed25519">>, {_, {namedCurve, ?'id-Ed25519'}}, _) -> true;
186 | type_sig(<<"ssh-ed448">>, {_, {namedCurve, ?'id-Ed448'}}, _) -> true;
187 | type_sig(<<"ssh-ed448">>, {ed_pub, ed448, _}, _) -> true;
188 | type_sig(<<"rsa-sha2-256">>, #'RSAPublicKey'{}, sha256) -> true;
189 | type_sig(<<"rsa-sha2-512">>, #'RSAPublicKey'{}, sha512) -> true;
190 | type_sig(_, _, _) -> false.
191 |
192 | -spec priv_to_public(public_key:private_key()) -> public_key:public_key().
193 | priv_to_public({ed_pri, _, _, _} = Priv) ->
194 | ed_to_pub(Priv);
195 | priv_to_public(#'ECPrivateKey'{} = Priv) ->
196 | ed_to_pub(Priv);
197 | priv_to_public(#'RSAPrivateKey'{modulus = Mod, publicExponent = Exp}) ->
198 | #'RSAPublicKey'{modulus = Mod, publicExponent = Exp};
199 | priv_to_public(Other) ->
200 | Other.
201 |
202 | -if(?OTP_RELEASE < 25).
203 | ed_to_pub({ed_pri, Type, Pub, _Priv}) ->
204 | {ed_pub, Type, Pub};
205 | ed_to_pub(#'ECPrivateKey'{
206 | parameters = {namedCurve, ?'id-Ed25519'}, publicKey = Pub
207 | }) ->
208 | {ed_pub, ed25519, Pub};
209 | ed_to_pub(#'ECPrivateKey'{
210 | parameters = {namedCurve, ?'id-Ed448'}, publicKey = Pub
211 | }) ->
212 | {ed_pub, ed448, Pub}.
213 | -else.
214 | ed_to_pub({ed_pri, Type, Pub, _Priv}) ->
215 | {#'ECPoint'{point = Pub}, Type};
216 | ed_to_pub(#'ECPrivateKey'{parameters = Type, publicKey = Pub}) ->
217 | {#'ECPoint'{point = Pub}, Type}.
218 | -endif.
219 |
220 | -spec encode(public_key:public_key()) -> binary().
221 | -spec decode(binary()) -> public_key:public_key().
222 | -if(?OTP_RELEASE < 24).
223 | encode(Key) ->
224 | public_key:ssh_encode(Key, ssh2_pubkey).
225 |
226 | decode(Key) ->
227 | public_key:ssh_decode(Key, ssh2_pubkey).
228 | -else.
229 | encode(Key) ->
230 | ssh_file:encode(Key, ssh2_pubkey).
231 |
232 | decode(Key) ->
233 | ssh_file:decode(Key, ssh2_pubkey).
234 | -endif.
235 |
236 | split(<>) ->
237 | [D, $\n | split(Rest)];
238 | split(Rest) ->
239 | Rest.
240 |
--------------------------------------------------------------------------------
/test/ssh_signature_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(ssh_signature_SUITE).
2 |
3 | -include_lib("public_key/include/public_key.hrl").
4 |
5 | % -compile(export_all).
6 |
7 | -export([
8 | all/0,
9 | groups/0,
10 | init_per_group/2,
11 | end_per_group/2,
12 | can_verify_signed/1,
13 | signature_can_be_verified_by_openssh/1,
14 | openssh_can_be_verified/1
15 | ]).
16 |
17 | -include_lib("stdlib/include/assert.hrl").
18 | -include_lib("common_test/include/ct.hrl").
19 |
20 | all() ->
21 | [
22 | {group, ed25519},
23 | {group, rsa2048},
24 | {group, rsa3072},
25 | {group, rsa4096}
26 | ].
27 |
28 | groups() ->
29 | Tests = [
30 | can_verify_signed,
31 | signature_can_be_verified_by_openssh,
32 | openssh_can_be_verified
33 | ],
34 | Hashes = [sha256, sha512],
35 | HashTests = [{Hash, [parallel], Tests} || Hash <- Hashes],
36 | Algos = [ed25519, rsa2048, rsa3072, rsa4096],
37 | [{Algo, [parallel], HashTests} || Algo <- Algos].
38 |
39 | init_per_group(basic, Config) ->
40 | Config;
41 | init_per_group(GroupName, Config) when
42 | GroupName =:= sha256; GroupName =:= sha512
43 | ->
44 | [{hash, GroupName} | Config];
45 | init_per_group(GroupName, Config) when
46 | GroupName =:= ed25519;
47 | GroupName =:= rsa2048;
48 | GroupName =:= rsa3072;
49 | GroupName =:= rsa4096
50 | ->
51 | try
52 | Key = create_key(GroupName),
53 | ct:log("Key = ~p.", [Key]),
54 | [{key, Key}, {algo, GroupName} | Config]
55 | catch
56 | _C:_R:_S ->
57 | {skip, "Algorithm not supported"}
58 | end.
59 |
60 | create_key(ed25519) ->
61 | public_key:generate_key({namedCurve, ?'id-Ed25519'});
62 | create_key(ed448) ->
63 | public_key:generate_key({namedCurve, ?'id-Ed448'});
64 | create_key(rsa2048) ->
65 | public_key:generate_key({rsa, 2048, 3});
66 | create_key(rsa3072) ->
67 | public_key:generate_key({rsa, 3072, 3});
68 | create_key(rsa4096) ->
69 | public_key:generate_key({rsa, 4096, 3}).
70 |
71 | end_per_group(_, Config) ->
72 | Config.
73 |
74 | can_verify_signed(Config) ->
75 | Key = ?config(key, Config),
76 | Hash = ?config(hash, Config),
77 | Data = crypto:strong_rand_bytes(256),
78 |
79 | PubKey = ssh_signature:priv_to_public(Key),
80 |
81 | Signature = ssh_signature:sign(Data, Key, <<"file">>, #{hash => Hash}),
82 | ct:log("Signature = ~s.", [Signature]),
83 |
84 | ?assertMatch(
85 | {ok, #{public_key := PubKey}}, ssh_signature:verify(Data, Signature)
86 | ).
87 |
88 | signature_can_be_verified_by_openssh(Config) ->
89 | ct:make_priv_dir(),
90 | Key = ?config(key, Config),
91 | Hash = ?config(hash, Config),
92 | PrivDir = ?config(priv_dir, Config),
93 |
94 | DataFile = filename:join(PrivDir, "data"),
95 | AllowedSignersFile = filename:join(PrivDir, "key"),
96 | SigFile = filename:join(PrivDir, "data.sig"),
97 |
98 | Data = crypto:strong_rand_bytes(256),
99 | ExportedKey = encode([{ssh_signature:priv_to_public(Key), []}]),
100 |
101 | Signature = ssh_signature:sign(Data, Key, <<"file">>, #{hash => Hash}),
102 |
103 | ct:log("DataB64 = ~s.", [base64:encode(Data)]),
104 | ct:log("ExportedKey = ~s.", [ExportedKey]),
105 | ct:log("Signature = ~s.", [Signature]),
106 | file:write_file(DataFile, Data),
107 | file:write_file(AllowedSignersFile, ["test@example.com ", ExportedKey]),
108 | file:write_file(SigFile, Signature),
109 |
110 | ?assert(
111 | ssh_keygen(
112 | [
113 | "-Y",
114 | "verify",
115 | "-n",
116 | "file",
117 | "-f",
118 | AllowedSignersFile,
119 | "-I",
120 | "test@example.com",
121 | "-s",
122 | SigFile
123 | ],
124 | Data
125 | )
126 | ),
127 |
128 | Config.
129 |
130 | openssh_can_be_verified(Config) ->
131 | ct:make_priv_dir(),
132 | Algo = ?config(algo, Config),
133 | Hash = ?config(hash, Config),
134 | PrivDir = ?config(priv_dir, Config),
135 |
136 | PrivKeyFile = filename:join(PrivDir, "key"),
137 | PubKeyFile = filename:join(PrivDir, "key.pub"),
138 | DataFile = filename:join(PrivDir, "data"),
139 | SigFile = filename:join(PrivDir, "data.sig"),
140 |
141 | {Type, ExtraArgs} = openssh_keygen_args(Algo, Hash),
142 | true = ssh_keygen(
143 | ["-t", Type, "-f", PrivKeyFile, "-N", ""] ++ ExtraArgs, ""
144 | ),
145 |
146 | {ok, PubKeyPEM} = file:read_file(PubKeyFile),
147 |
148 | ct:log("PubKeyPEM = ~s.", [PubKeyPEM]),
149 |
150 | Data = crypto:strong_rand_bytes(256),
151 | ct:log("Data = ~s.", [base64:encode(Data)]),
152 | file:write_file(DataFile, Data),
153 |
154 | [{PK, _}] = decode(PubKeyPEM),
155 |
156 | true = ssh_keygen(
157 | ["-Y", "sign", "-f", PrivKeyFile, "-n", "test", DataFile], ""
158 | ),
159 |
160 | {ok, Signature} = file:read_file(SigFile),
161 |
162 | {ok, Result} = ssh_signature:verify(Data, Signature),
163 | ?assertMatch(#{public_key := PK}, Result),
164 |
165 | Config.
166 |
167 | openssh_keygen_args(ed25519, _) -> {"ed25519", []};
168 | openssh_keygen_args(rsa2048, H) -> {rsa(H), ["-b", "2048"]};
169 | openssh_keygen_args(rsa3072, H) -> {rsa(H), ["-b", "3072"]};
170 | openssh_keygen_args(rsa4096, H) -> {rsa(H), ["-b", "4096"]}.
171 |
172 | rsa(sha256) -> "rsa-sha2-256";
173 | rsa(sha512) -> "rsa-sha2-512".
174 |
175 | ssh_keygen(Args, Input) ->
176 | Stdout = fun(_, _, Data) ->
177 | ct:pal("ssh-keygen -> ~s", [Data])
178 | end,
179 | exec:start(),
180 | Exec = os:find_executable("ssh-keygen"),
181 | {ok, Pid, _OsPid} = exec:run([Exec | Args], [
182 | stdin, monitor, {stdout, Stdout}
183 | ]),
184 | case string:is_empty(Input) of
185 | false ->
186 | exec:send(Pid, Input),
187 | exec:send(Pid, eof);
188 | true ->
189 | ok
190 | end,
191 | receive
192 | {'DOWN', _, process, _, normal} ->
193 | true
194 | after 2000 ->
195 | false
196 | end.
197 |
198 | -if(?OTP_RELEASE < 24).
199 | encode(Key) ->
200 | public_key:ssh_encode(Key, openssh_public_key).
201 |
202 | decode(Key) ->
203 | public_key:ssh_decode(Key, openssh_public_key).
204 | -else.
205 | encode(Key) ->
206 | ssh_file:encode(Key, openssh_key).
207 |
208 | decode(Key) ->
209 | ssh_file:decode(Key, openssh_key).
210 | -endif.
211 |
--------------------------------------------------------------------------------