KiCad PCB EDA Suite
Loading...
Searching...
No Matches
kigit_known_hosts.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.TXT for contributors.
5 *
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 3
9 * of the License, or (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20#include "kigit_known_hosts.h"
21#include "kigit_ssh_keys.h"
22
24
25#include <boost/uuid/detail/sha1.hpp>
26#include <boost/version.hpp>
27#include <picosha2.h>
28
29#include <cstdlib>
30#include <fstream>
31#include <iterator>
32#include <vector>
33
34#ifdef _WIN32
35#include <io.h>
36#include <windows.h>
37#else
38#include <cerrno>
39#include <fcntl.h>
40#include <pwd.h>
41#include <unistd.h>
42#endif
43
44#include <wx/arrstr.h>
45#include <wx/base64.h>
46#include <wx/filename.h>
47#include <wx/intl.h>
48#include <wx/log.h>
49#include <wx/tokenzr.h>
50
51
52namespace
53{
54
55std::array<uint8_t, 20> sha1( const std::string& aData )
56{
57 boost::uuids::detail::sha1 hasher;
58 hasher.process_bytes( aData.data(), aData.size() );
59
60 std::array<uint8_t, 20> result;
61
62 // Boost 1.86 changed the digest from five words to twenty bytes
63#if BOOST_VERSION >= 108600
64 unsigned char digest[20];
65 hasher.get_digest( digest );
66 std::copy( digest, digest + 20, result.begin() );
67#else
68 unsigned int digest[5];
69 hasher.get_digest( digest );
70
71 for( size_t ii = 0; ii < 5; ++ii )
72 {
73 for( size_t jj = 0; jj < 4; ++jj )
74 result[ii * 4 + jj] = static_cast<uint8_t>( digest[ii] >> ( 24 - 8 * jj ) );
75 }
76#endif
77
78 return result;
79}
80
81
82std::string decodeBase64( const wxString& aText )
83{
84 size_t errorPos = 0;
85 wxMemoryBuffer buffer = wxBase64Decode( aText, wxBase64DecodeMode_Strict, &errorPos );
86
87 return std::string( static_cast<const char*>( buffer.GetData() ), buffer.GetDataLen() );
88}
89
90
91wxString hostToken( const wxString& aHost, int aPort )
92{
93 if( aPort <= 0 || aPort == 22 )
94 return aHost.Lower();
95
96 return wxString::Format( wxS( "[%s]:%d" ), aHost.Lower(), aPort );
97}
98
99
100// ssh hashes the lower-cased name, and compares plain patterns ignoring case
101bool hostsFieldMatches( const wxString& aField, const wxString& aToken )
102{
103 if( aField.StartsWith( wxS( "|1|" ) ) )
104 {
105 wxArrayString parts = wxSplit( aField.Mid( 3 ), '|', '\0' );
106
107 if( parts.size() != 2 )
108 return false;
109
110 std::array<uint8_t, 20> mac = KIGIT::HmacSha1( decodeBase64( parts[0] ), aToken.utf8_string() );
111
112 return decodeBase64( parts[1] ) == std::string( mac.begin(), mac.end() );
113 }
114
115 bool matched = false;
116
117 for( const wxString& pattern : wxSplit( aField.Lower(), ',', '\0' ) )
118 {
119 const bool negated = pattern.StartsWith( wxS( "!" ) );
120
121 if( !aToken.Matches( negated ? pattern.Mid( 1 ) : pattern ) )
122 continue;
123
124 if( negated )
125 return false;
126
127 matched = true;
128 }
129
130 return matched;
131}
132
133
134// A path list as libgit2 keeps one, where a backslash escapes the separator
135wxString firstExistingDir( const wxString& aList )
136{
137 wxString current;
138
139 for( size_t ii = 0; ii <= aList.size(); ++ii )
140 {
141 if( ii < aList.size() && ( aList[ii] != GIT_PATH_LIST_SEPARATOR || ( ii > 0 && aList[ii - 1] == '\\' ) ) )
142 {
143 current += aList[ii];
144 continue;
145 }
146
147 if( !current.IsEmpty() && wxFileName::DirExists( current ) )
148 return current;
149
150 current.clear();
151 }
152
153 return wxEmptyString;
154}
155
156
157// ssh would accept any key a writable trust store gained, so a file we create is the owner's alone
158bool appendToFile( const wxString& aPath, const std::string& aText )
159{
160#ifdef _WIN32
161 std::ofstream out( aPath.fn_str(), std::ios::binary | std::ios::app );
162 out << aText;
163 out.flush();
164
165 return out.good();
166#else
167 struct FILE_DESCRIPTOR
168 {
169 int fd;
170
171 ~FILE_DESCRIPTOR()
172 {
173 if( fd >= 0 )
174 close( fd );
175 }
176 } file{ open( aPath.fn_str(), O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0600 ) };
177
178 if( file.fd < 0 )
179 return false;
180
181 for( size_t written = 0; written < aText.size(); )
182 {
183 ssize_t rc = write( file.fd, aText.data() + written, aText.size() - written );
184
185 if( rc < 0 && errno == EINTR )
186 continue;
187
188 if( rc <= 0 )
189 return false;
190
191 written += static_cast<size_t>( rc );
192 }
193
194 int fd = file.fd;
195 file.fd = -1;
196 return close( fd ) == 0;
197#endif
198}
199
200
201// Unknown counts as not ending in one, as a blank line costs nothing and a joined line loses an entry
202bool endsWithNewline( const wxString& aPath )
203{
204 if( !wxFileName::FileExists( aPath ) )
205 return true;
206
207 std::ifstream in( aPath.fn_str(), std::ios::binary | std::ios::ate );
208
209 if( !in )
210 return false;
211
212 std::streamoff size = in.tellg();
213
214 if( size == 0 )
215 return true;
216
217 char last = 0;
218 in.seekg( size - 1 );
219 in.get( last );
220
221 return in && last == '\n';
222}
223
224} // namespace
225
226
227std::array<uint8_t, 20> KIGIT::HmacSha1( const std::string& aKey, const std::string& aData )
228{
229 constexpr size_t blockSize = 64;
230
231 std::string key = aKey;
232
233 if( key.size() > blockSize )
234 {
235 std::array<uint8_t, 20> hashed = sha1( key );
236 key.assign( hashed.begin(), hashed.end() );
237 }
238
239 key.resize( blockSize, '\0' );
240
241 std::string inner( blockSize, '\0' );
242 std::string outer( blockSize, '\0' );
243
244 for( size_t ii = 0; ii < blockSize; ++ii )
245 {
246 inner[ii] = static_cast<char>( key[ii] ^ 0x36 );
247 outer[ii] = static_cast<char>( key[ii] ^ 0x5c );
248 }
249
250 std::array<uint8_t, 20> innerHash = sha1( inner + aData );
251
252 return sha1( outer + std::string( innerHash.begin(), innerHash.end() ) );
253}
254
255
256KIGIT::KNOWN_HOST_STATE KIGIT::LookupKnownHost( const wxString& aKnownHostsText, const wxString& aHost, int aPort,
257 const wxString& aKeyType, const std::string& aRawKey )
258{
259 const int port = aPort > 0 ? aPort : 22;
260 const wxString plain = hostToken( aHost, 22 );
261
262 // libssh2 tries [host]:port before host, even for port 22
263 const wxString bracketed = wxString::Format( wxS( "[%s]:%d" ), aHost.Lower(), port );
264
265 bool revoked = false;
266 bool matched = false;
267 bool mismatched = false;
268 bool otherType = false;
269
270 for( wxString line : wxSplit( aKnownHostsText, '\n', '\0' ) )
271 {
272 line.Trim( true ).Trim( false );
273
274 if( line.IsEmpty() || line.StartsWith( wxS( "#" ) ) )
275 continue;
276
277 wxArrayString fields = wxStringTokenize( line, wxS( " \t" ), wxTOKEN_STRTOK );
278 wxString marker;
279
280 if( !fields.IsEmpty() && fields[0].StartsWith( wxS( "@" ) ) )
281 {
282 marker = fields[0];
283 fields.RemoveAt( 0 );
284 }
285
286 // Neither KiCad nor libgit2 checks host certificates, so a CA line vouches for nothing here
287 if( fields.size() < 3 || ( !marker.IsEmpty() && marker != wxS( "@revoked" ) ) )
288 continue;
289
290 const bool sameKey = decodeBase64( fields[2] ) == aRawKey;
291
292 if( !marker.IsEmpty() )
293 {
294 if( sameKey && ( hostsFieldMatches( fields[0], bracketed ) || hostsFieldMatches( fields[0], plain ) ) )
295 revoked = true;
296
297 continue;
298 }
299
300 if( !hostsFieldMatches( fields[0], bracketed ) && ( port != 22 || !hostsFieldMatches( fields[0], plain ) ) )
301 continue;
302
303 if( sameKey )
304 matched = true;
305 else if( fields[1] == aKeyType )
306 mismatched = true;
307 else
308 otherType = true;
309 }
310
311 if( revoked )
313
314 if( matched )
316
317 if( mismatched )
319
321}
322
323
324wxString KIGIT::FormatKnownHostsLine( const wxString& aHost, int aPort, const wxString& aKeyType,
325 const wxString& aBase64Key )
326{
327 return hostToken( aHost, aPort ) + wxS( " " ) + aKeyType + wxS( " " ) + aBase64Key;
328}
329
330
331wxString KIGIT::HostKeyType( const std::string& aRawKey )
332{
333 if( aRawKey.size() < 4 )
334 return wxEmptyString;
335
336 const unsigned char* data = reinterpret_cast<const unsigned char*>( aRawKey.data() );
337 uint32_t nameLen = 0;
338
339 for( size_t ii = 0; ii < 4; ++ii )
340 nameLen = ( nameLen << 8 ) | data[ii];
341
342 if( nameLen == 0 || nameLen > aRawKey.size() - 4 )
343 return wxEmptyString;
344
345 return wxString::FromUTF8( aRawKey.substr( 4, nameLen ) );
346}
347
348
349wxString KIGIT::HostKeyFingerprint( const std::string& aRawKey )
350{
351 std::vector<unsigned char> digest( picosha2::k_digest_size );
352 picosha2::hash256( aRawKey.begin(), aRawKey.end(), digest.begin(), digest.end() );
353
354 wxString encoded = wxBase64Encode( digest.data(), digest.size() );
355 encoded.Replace( wxS( "=" ), wxEmptyString );
356 return encoded;
357}
358
359
361{
362 wxString home;
363
364 // libgit2 picks the first existing folder of this list, so asking it is the only way to agree
365#if KIGIT_HAS_HOMEDIR_OPT
366 git_buf homeList = GIT_BUF_INIT;
367
368 if( git_libgit2_opts( GIT_OPT_GET_HOMEDIR, &homeList ) == GIT_OK && homeList.ptr )
369 home = firstExistingDir( wxString::FromUTF8( homeList.ptr ) );
370
371 git_buf_dispose( &homeList );
372#else
373 // The headers can be older than the library, which the distribution may update on its own
374 int major = 0;
375 int minor = 0;
376 int revision = 0;
377 git_libgit2_version( &major, &minor, &revision );
378 home = Libgit2LegacyHome( revision );
379#endif
380
381 if( home.IsEmpty() )
382 return wxEmptyString;
383
384 wxFileName path( home, wxS( "known_hosts" ) );
385 path.AppendDir( wxS( ".ssh" ) );
386 return path.GetFullPath();
387}
388
389
391{
392 const wxString path = KnownHostsPath();
393
394 if( path.IsEmpty() )
395 return wxEmptyString;
396
397 std::ifstream in( path.fn_str(), std::ios::binary );
398
399 if( !in )
400 return wxEmptyString;
401
402 std::string text( ( std::istreambuf_iterator<char>( in ) ), std::istreambuf_iterator<char>() );
403 wxString decoded = wxString::FromUTF8( text );
404
405 // A comment in a legacy encoding must not hide every entry
406 if( decoded.IsEmpty() && !text.empty() )
407 decoded = wxString( text.data(), wxConvISO8859_1, text.size() );
408
409 return decoded;
410}
411
412
414{
415 const wxString path = KnownHostsPath();
416
417 if( path.IsEmpty() || aKey.keyType.IsEmpty() || aKey.rawKey.empty() )
418 return false;
419
420 // The type comes from the server, and whitespace or a line break in it would write entries of its own
421 for( wxUniChar c : aKey.keyType )
422 {
423 if( c <= ' ' || wxIscntrl( c ) )
424 return false;
425 }
426
427 wxFileName fn( path );
428
429 // The prompt explains a failure itself
430 wxLogNull quiet;
431
432 // ssh refuses a .ssh folder others can write to
433 if( !fn.DirExists() && !fn.Mkdir( 0700, wxPATH_MKDIR_FULL ) )
434 return false;
435
436 const wxString encoded = wxBase64Encode( aKey.rawKey.data(), aKey.rawKey.size() );
437 std::string line = FormatKnownHostsLine( aKey.host, aKey.port, aKey.keyType, encoded ).utf8_string() + "\n";
438
439 if( !endsWithNewline( path ) )
440 line.insert( 0, "\n" );
441
442 return appendToFile( path, line );
443}
444
445
446bool KIGIT::MayOfferTrust( const HOST_KEY_FAILURE& aFailure, bool aRetriedAfterTrust )
447{
448 if( aRetriedAfterTrust )
449 return false;
450
452}
453
454
456{
457 wxString host = aFailure.host;
458
459 if( aFailure.port != 22 )
460 host << wxS( ":" ) << aFailure.port;
461
462 if( aFailure.state == KNOWN_HOST_STATE::REVOKED )
463 {
464 return wxString::Format( _( "The key for %s has been revoked. Ask your administrator before connecting "
465 "again." ),
466 host );
467 }
468
469 if( aFailure.state == KNOWN_HOST_STATE::MISMATCH )
470 {
471 return wxString::Format( _( "The key for %s has changed since the last connection. This can mean the server "
472 "was replaced or that someone is intercepting the connection. Ask your "
473 "administrator before connecting again." ),
474 host );
475 }
476
477 return wxString::Format( _( "%s still could not be verified after its key was saved. Ask your administrator "
478 "before connecting again." ),
479 host );
480}
481
482
483wxString KIGIT::Libgit2LegacyHome( int aRevision )
484{
485#ifdef _WIN32
486 // 1.5.1 joins HOME as it stands at each connection
487 if( aRevision == 1 )
488 {
489 DWORD len = GetEnvironmentVariableW( L"HOME", nullptr, 0 );
490 std::vector<wchar_t> value( len + 1 );
491
492 if( len == 0 || GetEnvironmentVariableW( L"HOME", value.data(), len ) == 0 )
493 return wxEmptyString;
494
495 return wxString( value.data() );
496 }
497
498 // libgit2's GIT_WIN_PATH_UTF16
499 constexpr DWORD bufferSize = 4096 + 6;
500
501 // The others keep the first of these that exists, expanded as libgit2's find_win32_dirs does
502 for( const wchar_t* tmpl : { L"%HOME%\\", L"%HOMEDRIVE%%HOMEPATH%\\", L"%USERPROFILE%\\" } )
503 {
504 std::vector<wchar_t> path( bufferSize );
505 DWORD len = ExpandEnvironmentStringsW( tmpl, path.data(), bufferSize );
506
507 if( len == 0 || len > bufferSize || path[0] == L'%' || _waccess( path.data(), 0 ) != 0 )
508 continue;
509
510 return wxString( path.data() );
511 }
512
513 return wxEmptyString;
514#else
515 wxString home;
516
517 // 1.5.2 trusts the passwd entry over HOME when setuid or in a macOS sandbox
518 if( aRevision != 1 && ( std::getenv( "APP_SANDBOX_CONTAINER_ID" ) || getuid() != geteuid() ) )
519 {
520 passwd entry;
521 passwd* found = nullptr;
522 std::vector<char> buffer( 1024 );
523 int rc;
524
525 while( ( rc = getpwuid_r( geteuid(), &entry, buffer.data(), buffer.size(), &found ) ) == ERANGE
526 && buffer.size() < 65536 )
527 {
528 buffer.resize( buffer.size() * 2 );
529 }
530
531 if( rc == 0 && found && found->pw_dir )
532 home = wxString( found->pw_dir, wxConvLibc );
533 }
534 else if( const char* value = std::getenv( "HOME" ) )
535 {
536 home = wxString( value, wxConvLibc );
537 }
538
539 // 1.5.1 reads the file under HOME whether or not the folder exists
540 return aRevision == 1 ? home : firstExistingDir( home );
541#endif
542}
#define _(s)
#define GIT_BUF_INIT
APIEXPORT bool AddKnownHost(const HOST_KEY_FAILURE &aKey)
Append the refused key to KnownHostsPath(), creating the file and its folder when missing.
KNOWN_HOST_STATE
How a server's host key stands against known_hosts.
@ OTHER_KEY_TYPE
Entries name the host only with keys of other types.
@ MISMATCH
Entries name the host with another key of this type, and none with this one.
@ UNKNOWN
No entry names the host with a key of this type.
@ MATCH
An entry names the host with this key.
@ REVOKED
A @revoked entry carries this key.
APIEXPORT wxString Libgit2LegacyHome(int aRevision)
The home folder libgit2 1.5 reads .ssh/known_hosts under, which it has no option to report.
APIEXPORT wxString FormatKnownHostsLine(const wxString &aHost, int aPort, const wxString &aKeyType, const wxString &aBase64Key)
The known_hosts line ssh would write for a key, without a line ending.
APIEXPORT KNOWN_HOST_STATE LookupKnownHost(const wxString &aKnownHostsText, const wxString &aHost, int aPort, const wxString &aKeyType, const std::string &aRawKey)
Classify a server's key against the text of a known_hosts file, as OpenSSH reads it.
APIEXPORT wxString ReadKnownHosts()
The text of KnownHostsPath(). Empty when it is missing or cannot be read.
APIEXPORT bool MayOfferTrust(const HOST_KEY_FAILURE &aFailure, bool aRetriedAfterTrust)
Whether the user may be asked to trust a refused key.
APIEXPORT wxString HostKeyType(const std::string &aRawKey)
The type a raw SSH key names for itself, such as ssh-ed25519. Empty when the key is malformed.
APIEXPORT wxString HostKeyFingerprint(const std::string &aRawKey)
SHA256 of a raw key in unpadded base64, as ssh shows it after "SHA256:".
APIEXPORT wxString HostKeyRefusalMessage(const HOST_KEY_FAILURE &aFailure)
Why a key the user may not trust was refused, and what to do next.
APIEXPORT std::array< uint8_t, 20 > HmacSha1(const std::string &aKey, const std::string &aData)
HMAC-SHA1 (RFC 2104), as OpenSSH hashes host names in known_hosts.
APIEXPORT wxString KnownHostsPath()
The known_hosts file libgit2 checks host keys against. Empty when it has no home folder.
A server key the connection refused, for the UI to offer to trust or to report.
wxString keyType
As known_hosts names it, such as ssh-ed25519.
std::string rawKey
The key as the server sent it.
static std::string decoded(const std::string &aBase64)
std::string path
wxString result
Test unit parsing edge cases and error handling.