sources/Google.Solutions.IapDesktop.Extensions.Session/Protocol/Ssh/OsLoginProfile.cs (222 lines of code) (raw):

// // Copyright 2020 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using Google.Apis.CloudOSLogin.v1.Data; using Google.Solutions.Apis.Auth; using Google.Solutions.Apis.Auth.Iam; using Google.Solutions.Apis.Compute; using Google.Solutions.Apis.Diagnostics; using Google.Solutions.Apis.Locator; using Google.Solutions.Common.Diagnostics; using Google.Solutions.Common.Linq; using Google.Solutions.Common.Util; using Google.Solutions.IapDesktop.Application; using Google.Solutions.IapDesktop.Core.ObjectModel; using Google.Solutions.Ssh.Cryptography; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace Google.Solutions.IapDesktop.Extensions.Session.Protocol.Ssh { /// <summary> /// A user's OS Login profile. /// </summary> public interface IOsLoginProfile { /// <summary> /// Upload an a public key to authorize it. /// </summary> Task<PlatformCredential> AuthorizeKeyAsync( ZoneLocator zone, OsLoginSystemType os, IAsymmetricKeySigner key, TimeSpan validity, CancellationToken token); /// <summary> /// List existing authorized keys. /// </summary> Task<IEnumerable<IAuthorizedPublicKey>> ListAuthorizedKeysAsync( CancellationToken cancellationToken); /// <summary> /// Delete authorized key. /// </summary> Task DeleteAuthorizedKeyAsync( IAuthorizedPublicKey key, CancellationToken cancellationToken); } public enum OsLoginSystemType { Linux } [Service(typeof(IOsLoginProfile))] public sealed class OsLoginProfile : IOsLoginProfile { private static readonly ProjectLocator WellKnownProject = new ProjectLocator("windows-cloud"); private readonly IOsLoginClient client; private readonly IAuthorization authorization; internal static string LookupUsername(LoginProfile loginProfile) { // // Although rare, there could be multiple POSIX accounts. // var account = loginProfile.PosixAccounts .EnsureNotNull() .FirstOrDefault(a => a.Primary == true && a.OperatingSystemType == "LINUX"); if (account == null) { // // This is strange, the account should have been created. // throw new InvalidOsLoginProfileException( "The login profile does not contain a suitable POSIX account", HelpTopics.TroubleshootingOsLogin); } return account.Username; } //--------------------------------------------------------------------- // Ctor. //--------------------------------------------------------------------- public OsLoginProfile( IOsLoginClient adapter, IAuthorization authorization) { this.client = adapter.ExpectNotNull(nameof(adapter)); this.authorization = authorization.ExpectNotNull(nameof(authorization)); } //--------------------------------------------------------------------- // IOsLoginService. //--------------------------------------------------------------------- public async Task<PlatformCredential> AuthorizeKeyAsync( ZoneLocator zone, OsLoginSystemType os, IAsymmetricKeySigner key, TimeSpan validity, CancellationToken token) { Precondition.ExpectNotNull(zone, nameof(zone)); Precondition.ExpectNotNull(key, nameof(key)); if (os != OsLoginSystemType.Linux) { throw new ArgumentException(nameof(os)); } if (validity.TotalSeconds <= 0) { throw new ArgumentException(nameof(validity)); } using (ApplicationTraceSource.Log.TraceMethod().WithParameters(zone)) { // // If OS Login is enabled for a project, we have to use // the Posix username from the OS Login login profile. // // Note that the Posix account managed by OS login can // differ based on the project that we're trying to access. // Therefore, we specify the project when importing or // certifying the key. // // OS Login auto-generates a username for us. Again, this // username might differ based on project/organization. // var publicKey = key.PublicKey.ToString(PublicKey.Format.OpenSsh); if (this.authorization.Session is IWorkforcePoolSession) { // // Authorize the key by signing it. // // Note that we have no control over how long the // certified key remains valid. // var certifiedKey = await this.client .SignPublicKeyAsync( zone, publicKey, token) .ConfigureAwait(false); var certificateSigner = new OsLoginCertificateSigner( key, certifiedKey); return new PlatformCredential( certificateSigner, KeyAuthorizationMethods.Oslogin, certificateSigner.Username); } else { // // Authorize the key by importing it. // // NB. It's cheaper to unconditionally push the key than // to check for previous keys first. // var loginProfile = await this.client .ImportSshPublicKeyAsync( new ProjectLocator(zone.ProjectId), publicKey, validity, token) .ConfigureAwait(false); return new PlatformCredential( key, KeyAuthorizationMethods.Oslogin, LookupUsername(loginProfile)); } } } public async Task<IEnumerable<IAuthorizedPublicKey>> ListAuthorizedKeysAsync( CancellationToken cancellationToken) { using (ApplicationTraceSource.Log.TraceMethod().WithoutParameters()) { // // NB. The OS Login profile (in particular, the username // and UID/GID) depends on the project, and the organization // it resides in. However, the SSH public keys are independent // of that -- therefore, we can query the list of public keys // using any project. // // var loginProfile = await this.client .GetLoginProfileAsync(WellKnownProject, cancellationToken) .ConfigureAwait(false); return loginProfile .SshPublicKeys .EnsureNotNull() .Select(k => AuthorizedPublicKey.TryParse(k.Value)) .Where(k => k != null) .Select(k => k!) .ToList(); } } public async Task DeleteAuthorizedKeyAsync( IAuthorizedPublicKey key, CancellationToken cancellationToken) { Debug.Assert(key is AuthorizedPublicKey); using (ApplicationTraceSource.Log.TraceMethod().WithParameters(key)) { await this.client.DeleteSshPublicKeyAsync( ((AuthorizedPublicKey)key).Fingerprint, cancellationToken) .ConfigureAwait(false); } } internal class AuthorizedPublicKey : IAuthorizedPublicKey { internal string Fingerprint { get; } public string Email { get; } public string KeyType { get; } public string PublicKey { get; } public DateTime? ExpireOn { get; } private AuthorizedPublicKey( string fingerprint, string email, string keyType, string publicKey, DateTime? expiresOn) { this.Fingerprint = fingerprint.ExpectNotNull(nameof(fingerprint)); this.Email = email.ExpectNotEmpty(nameof(email)); this.KeyType = keyType.ExpectNotEmpty(nameof(keyType)); this.PublicKey = publicKey.ExpectNotEmpty(nameof(publicKey)); this.ExpireOn = expiresOn; } public static AuthorizedPublicKey? TryParse( SshPublicKey osLoginKey) { // // The key should be formatted as: // // <type> <key> // // But the API doesn't enforce that format, // so we might be encountering garbage. // var keyParts = osLoginKey.Key.Trim().Split(' '); // // The name should be formatted as: // // users/<email>/sshPublicKeys/<fingerprint> // var nameParts = osLoginKey.Name.Split('/'); if (keyParts.Length == 2 && nameParts.Length == 4 && nameParts[0] == "users" && nameParts[2] == "sshPublicKeys") { Debug.Assert(nameParts[3] == osLoginKey.Fingerprint); return new AuthorizedPublicKey( osLoginKey.Fingerprint, nameParts[1], keyParts[0], keyParts[1], osLoginKey.ExpirationTimeUsec != null ? (DateTime?)DateTimeOffsetExtensions .FromUnixTimeMicroseconds(osLoginKey.ExpirationTimeUsec.Value) .Date : null); } else { return null; } } public override string ToString() => this.Fingerprint; } } public class InvalidOsLoginProfileException : Exception, IExceptionWithHelpTopic { public IHelpTopic Help { get; } public InvalidOsLoginProfileException( string message, IHelpTopic helpTopic) : base(message) { this.Help = helpTopic; } } public class OsLoginSkNotSupportedException : Exception, IExceptionWithHelpTopic { public IHelpTopic Help { get; } public OsLoginSkNotSupportedException( string message, IHelpTopic helpTopic) : base(message) { this.Help = helpTopic; } } }