using System.Net; using System.Security.Cryptography; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.WebUtilities; namespace DodoSSH.Api.Features.Relay; /// /// Everything the relay socket needs to know, decided by the ticket endpoint. /// /// /// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it /// to resolve again — is carried for the audit row only — which is what keeps a /// DNS answer that changes between issue and connect from changing where the bytes go. /// /// The replay guard's key. /// The audit row's id, already given to the client. /// Who asked. /// The host it is for. /// The host's vault at issue. /// The hostname as stored, for the audit row. /// The permitted addresses the name resolved to, in the order to try. /// The port. /// When the ticket stops verifying. internal sealed record RelayTicket( Guid TicketId, Guid SessionId, Guid UserId, Guid HostId, Guid VaultId, string TargetHost, IReadOnlyList TargetAddresses, int TargetPort, DateTimeOffset ExpiresAt); /// /// Seals and opens relay tickets. /// /// /// /// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by /// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely /// signed because the ticket travels in a header every proxy on the way can read, and what it holds is /// the resolved address of a machine on the operator's network. /// /// /// The expiry is inside the payload and checked against , rather than left to /// the framework's time-limited protector, which reads the system clock directly — the thing this /// repository bans so that time can be faked in tests. /// /// /// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would /// be the larger half of the code, and a hand layout has no reflection for trimming to break. /// /// internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock) { /// The purpose string, which isolates these payloads from every other protector's. private const string Purpose = "DodoSSH.Relay.Ticket"; private const byte LayoutVersion = 1; /// More addresses than any real name resolves to, and a bound on what a ticket can carry. internal const int MaxAddresses = 16; private readonly IDataProtector protector = protection.CreateProtector(Purpose); internal string Seal(RelayTicket ticket) { ArgumentNullException.ThrowIfNull(ticket); using var buffer = new MemoryStream(); using (var writer = new BinaryWriter(buffer)) { writer.Write(LayoutVersion); writer.Write(ticket.TicketId.ToByteArray()); writer.Write(ticket.SessionId.ToByteArray()); writer.Write(ticket.UserId.ToByteArray()); writer.Write(ticket.HostId.ToByteArray()); writer.Write(ticket.VaultId.ToByteArray()); writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds()); writer.Write(ticket.TargetPort); writer.Write(ticket.TargetHost); writer.Write((byte)ticket.TargetAddresses.Count); foreach (var address in ticket.TargetAddresses) { var bytes = address.GetAddressBytes(); writer.Write((byte)bytes.Length); writer.Write(bytes); } } // Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name. return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray())); } /// Opens a ticket, or answers null for one that is forged, damaged or expired. /// /// One answer for all three, because the caller's remedy is the same — ask for another — and a /// distinct "expired" would tell a forger which of their attempts had verified. /// internal RelayTicket? TryOpen(string sealedTicket) { byte[] payload; try { payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket)); } catch (Exception exception) when (exception is CryptographicException or FormatException) { return null; } RelayTicket ticket; try { ticket = Read(payload); } catch (Exception exception) when (exception is EndOfStreamException or ArgumentException or FormatException) { return null; } return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null; } private static RelayTicket Read(byte[] payload) { using var reader = new BinaryReader(new MemoryStream(payload)); if (reader.ReadByte() != LayoutVersion) { throw new FormatException("Unknown ticket layout."); } var ticketId = new Guid(reader.ReadBytes(16)); var sessionId = new Guid(reader.ReadBytes(16)); var userId = new Guid(reader.ReadBytes(16)); var hostId = new Guid(reader.ReadBytes(16)); var vaultId = new Guid(reader.ReadBytes(16)); var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64()); var port = reader.ReadInt32(); var host = reader.ReadString(); var count = reader.ReadByte(); if (count is 0 or > MaxAddresses) { throw new FormatException("A ticket names between one and sixteen addresses."); } var addresses = new IPAddress[count]; for (var index = 0; index < count; index++) { var length = reader.ReadByte(); if (length is not (4 or 16)) { throw new FormatException("An address is four or sixteen bytes."); } addresses[index] = new IPAddress(reader.ReadBytes(length)); } return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt); } }