@@ -6,10 +6,18 @@ package sshutil
66import (
77 "bytes"
88 "context"
9+ "crypto"
10+ "crypto/ecdsa"
11+ "crypto/ed25519"
12+ "crypto/elliptic"
13+ "crypto/rand"
14+ "crypto/rsa"
915 "encoding/base64"
1016 "encoding/binary"
17+ "encoding/pem"
1118 "errors"
1219 "fmt"
20+ "io"
1321 "io/fs"
1422 "net"
1523 "os"
@@ -26,8 +34,10 @@ import (
2634 "github.com/mattn/go-shellwords"
2735 "github.com/sirupsen/logrus"
2836 "golang.org/x/crypto/ssh"
37+ "golang.org/x/crypto/ssh/knownhosts"
2938 "golang.org/x/sys/cpu"
3039
40+ "github.com/lima-vm/lima/v2/pkg/instance/hostname"
3141 "github.com/lima-vm/lima/v2/pkg/ioutilx"
3242 "github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
3343 "github.com/lima-vm/lima/v2/pkg/limatype/filenames"
@@ -244,7 +254,6 @@ func CommonOpts(ctx context.Context, sshExe SSHExe, useDotSSH bool) ([]string, e
244254
245255 opts = append (opts ,
246256 "StrictHostKeyChecking=no" ,
247- "UserKnownHostsFile=/dev/null" ,
248257 "NoHostAuthenticationForLocalhost=yes" ,
249258 "PreferredAuthentications=publickey" ,
250259 "Compression=no" ,
@@ -345,18 +354,28 @@ func SSHOpts(ctx context.Context, sshExe SSHExe, instDir, username string, useDo
345354 return nil , err
346355 }
347356 controlPath := fmt .Sprintf (`ControlPath="%s"` , controlSock )
357+ userKnownHostsPath := filepath .Join (instDir , filenames .SSHKnownHosts )
358+ userKnownHosts := fmt .Sprintf (`UserKnownHostsFile="%s"` , userKnownHostsPath )
348359 if runtime .GOOS == "windows" {
349360 controlSock , err = ioutilx .WindowsSubsystemPath (ctx , controlSock )
350361 if err != nil {
351362 return nil , err
352363 }
353364 controlPath = fmt .Sprintf (`ControlPath='%s'` , controlSock )
365+ userKnownHostsPath , err = ioutilx .WindowsSubsystemPath (ctx , userKnownHostsPath )
366+ if err != nil {
367+ return nil , err
368+ }
369+ userKnownHosts = fmt .Sprintf (`UserKnownHostsFile='%s'` , userKnownHostsPath )
354370 }
371+ hostKeyAlias := fmt .Sprintf ("HostKeyAlias=%s" , hostname .FromInstName (filepath .Base (instDir )))
355372 opts = append (opts ,
356373 fmt .Sprintf ("User=%s" , username ), // guest and host have the same username, but we should specify the username explicitly (#85)
357374 "ControlMaster=auto" ,
358375 controlPath ,
359376 "ControlPersist=yes" ,
377+ userKnownHosts ,
378+ hostKeyAlias ,
360379 )
361380 if forwardAgent {
362381 opts = append (opts , "ForwardAgent=yes" )
@@ -516,27 +535,27 @@ func detectAESAcceleration() bool {
516535// The dialContext function is used to create a connection to the SSH server.
517536// The addr, user, privateKeyPath parameter is used for ssh.ClientConn creation.
518537// The timeoutSeconds parameter specifies the maximum number of seconds to wait.
519- func WaitSSHReady (ctx context.Context , dialContext func (context.Context ) (net.Conn , error ), addr , user , privateKeyPath string , timeoutSeconds int ) error {
538+ func WaitSSHReady (ctx context.Context , dialContext func (context.Context ) (net.Conn , error ), addr , user , instanceName string , timeoutSeconds int ) error {
520539 ctx , cancel := context .WithTimeout (ctx , time .Duration (timeoutSeconds )* time .Second )
521540 defer cancel ()
522541
523542 // Prepare signer
524- key , err := os . ReadFile ( privateKeyPath )
543+ signer , err := UserPrivateKey ( )
525544 if err != nil {
526- return fmt . Errorf ( "failed to read private key %q: %w" , privateKeyPath , err )
545+ return err
527546 }
528- signer , err := ssh .ParsePrivateKey (key )
547+ // Prepare HostKeyCallback
548+ hostKeyChecker , err := HostKeyCheckerWithKeysInKnownHosts (instanceName )
529549 if err != nil {
530- return fmt . Errorf ( "failed to parse private key %q: %w" , privateKeyPath , err )
550+ return err
531551 }
532552 // Prepare ssh client config
533553 sshConfig := & ssh.ClientConfig {
534554 User : user ,
535555 Auth : []ssh.AuthMethod {ssh .PublicKeys (signer )},
536- HostKeyCallback : ssh . InsecureIgnoreHostKey () ,
556+ HostKeyCallback : hostKeyChecker ,
537557 Timeout : 10 * time .Second ,
538558 }
539-
540559 // Wait until the SSH server is available.
541560 for {
542561 conn , err := dialContext (ctx )
@@ -567,3 +586,105 @@ func isRetryableError(err error) bool {
567586 // SSH server not ready yet (e.g. host key not generated on initial boot).
568587 strings .HasSuffix (err .Error (), "no supported methods remain" )
569588}
589+
590+ // UserPrivateKey returns the user's private key signer.
591+ // The public key is always installed in the VM.
592+ func UserPrivateKey () (ssh.Signer , error ) {
593+ configDir , err := dirnames .LimaConfigDir ()
594+ if err != nil {
595+ return nil , err
596+ }
597+ privateKeyPath := filepath .Join (configDir , filenames .UserPrivateKey )
598+ key , err := os .ReadFile (privateKeyPath )
599+ if err != nil {
600+ return nil , fmt .Errorf ("failed to read private key %q: %w" , privateKeyPath , err )
601+ }
602+ signer , err := ssh .ParsePrivateKey (key )
603+ if err != nil {
604+ return nil , fmt .Errorf ("failed to parse private key %q: %w" , privateKeyPath , err )
605+ }
606+ return signer , nil
607+ }
608+
609+ func HostKeyCheckerWithKeysInKnownHosts (instanceName string ) (ssh.HostKeyCallback , error ) {
610+ publicKeys , err := PublicKeysFromKnownHosts (instanceName )
611+ if err != nil {
612+ return nil , err
613+ }
614+ return func (_ string , _ net.Addr , key ssh.PublicKey ) error {
615+ keyBytes := key .Marshal ()
616+ for _ , pk := range publicKeys {
617+ if bytes .Equal (keyBytes , pk .Marshal ()) {
618+ return nil
619+ }
620+ }
621+ return errors .New ("ssh: host key mismatch" )
622+ }, nil
623+ }
624+
625+ // PublicKeysFromKnownHosts returns the public keys from the known_hosts file located in the instance directory.
626+ func PublicKeysFromKnownHosts (instanceName string ) ([]ssh.PublicKey , error ) {
627+ // Load known_hosts from the instance directory
628+ instanceDir , err := dirnames .InstanceDir (instanceName )
629+ if err != nil {
630+ return nil , fmt .Errorf ("failed to get instance dir for instance %q: %w" , instanceName , err )
631+ }
632+ knownHostsPath := filepath .Join (instanceDir , filenames .SSHKnownHosts )
633+ knownHostsBytes , err := os .ReadFile (knownHostsPath )
634+ if err != nil {
635+ return nil , fmt .Errorf ("failed to read known_hosts file at %s: %w" , knownHostsPath , err )
636+ }
637+ var publicKeys []ssh.PublicKey
638+ rest := knownHostsBytes
639+ for len (rest ) > 0 {
640+ var publicKey ssh.PublicKey
641+ publicKey , _ , _ , rest , err = ssh .ParseAuthorizedKey (rest )
642+ if err != nil {
643+ return nil , fmt .Errorf ("failed to parse public key from known_hosts file %s: %w" , knownHostsPath , err )
644+ }
645+ publicKeys = append (publicKeys , publicKey )
646+ }
647+ return publicKeys , nil
648+ }
649+
650+ // GenerateSSHHostKeys generates an Ed25519 host key pair for the SSH server.
651+ // The private key is returned in PEM format, and the public key.
652+ func GenerateSSHHostKeys (instDir , hostname string ) (map [string ]string , error ) {
653+ generators := map [string ]func (io.Reader ) (crypto.PrivateKey , error ){
654+ "ecdsa" : func (rand io.Reader ) (crypto.PrivateKey , error ) {
655+ return ecdsa .GenerateKey (elliptic .P256 (), rand )
656+ },
657+ "ed25519" : func (rand io.Reader ) (crypto.PrivateKey , error ) {
658+ _ , priv , err := ed25519 .GenerateKey (rand )
659+ return priv , err
660+ },
661+ "rsa" : func (rand io.Reader ) (crypto.PrivateKey , error ) {
662+ return rsa .GenerateKey (rand , 3072 )
663+ },
664+ }
665+ res := make (map [string ]string , len (generators ))
666+ var sshKnownHosts []byte
667+ for keyType , generator := range generators {
668+ priv , err := generator (rand .Reader )
669+ if err != nil {
670+ return nil , err
671+ }
672+ privPem , err := ssh .MarshalPrivateKey (priv , hostname )
673+ if err != nil {
674+ return nil , fmt .Errorf ("failed to marshal %s private key to PEM format: %w" , keyType , err )
675+ }
676+ pub , err := ssh .NewPublicKey (priv .(crypto.Signer ).Public ())
677+ if err != nil {
678+ return nil , fmt .Errorf ("failed to create ssh %s public key: %w" , keyType , err )
679+ }
680+ res [keyType + "_private" ] = string (pem .EncodeToMemory (privPem ))
681+ res [keyType + "_public" ] = string (ssh .MarshalAuthorizedKey (pub ))
682+ sshKnownHosts = append (sshKnownHosts , knownhosts .Line ([]string {hostname }, pub )... )
683+ sshKnownHosts = append (sshKnownHosts , '\n' )
684+ }
685+ knownHostsPath := filepath .Join (instDir , filenames .SSHKnownHosts )
686+ if err := os .WriteFile (knownHostsPath , sshKnownHosts , 0o644 ); err != nil {
687+ return nil , fmt .Errorf ("failed to write known_hosts file at %s: %w" , knownHostsPath , err )
688+ }
689+ return res , nil
690+ }
0 commit comments