diff --git a/Documentation/Tickets.md b/Documentation/Tickets.md new file mode 100644 index 00000000..b0b5c8ae --- /dev/null +++ b/Documentation/Tickets.md @@ -0,0 +1,57 @@ +# PS3 Authentication Tickets + +Authentication tickets, commonly referred to as NpTickets or X-I-5 tickets are binary blobs used by third-party game servers +to identify and authenticate users. + +For more information about the format of tickets see either the [article written by the psdevwiki](https://www.psdevwiki.com/ps3/X-I-5-Ticket) +or the write-up done by the [Skate 3 server team](https://github.com/hallofmeat/Skateboard3Server/blob/master/docs/PS3Ticket.md) + +PSN uses ECDSA for securely verifying that a ticket was indeed generated by PSN and hasn't been tampered with + +The exact specifics of the system are unknown but it's believed that each game or in some instances groups of games (The LBP series for example) +are given a single unique identifier such that any tickets with that identifier are guaranteed to be signed +using the same private key. Therefore, with that identifier in hand you can determine what public +key you should use to verify a ticket. + +## ECDSA specifics +The PSN implementation of ECDSA signing uses the `secp192r1` curve also known as `nistp192`. The hashing algorithm used is `SHA-1`. +The message is the entire ticket sans the signature (the last 56 bytes). + +The RPCN implementation of ECDSA signing uses the `secp224k1` curve. The hashing algorithm used is `SHA-224`. +The message for RPCN is the body section of the ticket including the body header. +The public key for RPCN is available on [the RPCN repo](https://github.com/RipleyTom/rpcn/blob/master/ticket_public.pem) or in the [known keys section](#known-public-keys). + +## Finding public keys +Without going into the specifics of ECDSA it's possible to derive the public key given that you have the original message and know the curve +parameters and hashing algorithm. + +A C# program has been written that can parse tickets and give the public keys that correspond to the private key used to sign the message. + +The project is available on GitHub at https://github.com/Slendy/PubKeyFinder + +While it's possible to find the public key given a single ticket, due to the way ECDSA works there are always 2 possible +points that can validate a signature. If you have more than one ticket though then each ticket will have at least +one matching point which can be used to identify the actual public key. + +## Known Public Keys + +### RPCN (All Games) +``` +identifier: 5250434E (RPCN in ASCII) +x: b07bc0f0addb97657e9f389039e8d2b9c97dc2a31d3042e7d0479b93 +y: d81c42b0abdf6c42191a31e31f93342f8f033bd529c2c57fdb5a0a7d +``` + +### LittleBigPlanet Series (PSN) +``` +identfier: 719F1D4A +x: 39c62d061d4ee35c5f3f7531de0af3cf918346526edac727 +y: a5d578b55113e612bf1878d4cc939d61a41318403b5bdf86 +``` + +### Skate 3 (PSN) +``` +identifier: 382DE58D +x: a93f2d73da8fe51c59872fad192b832f8b9dabde8587233 +y: 93131936a54a0ea51117f74518e56aae95f6baff4b29f999 +``` \ No newline at end of file diff --git a/ProjectLighthouse.Tests/Unit/TicketTests.cs b/ProjectLighthouse.Tests/Unit/TicketTests.cs new file mode 100644 index 00000000..f1e5e462 --- /dev/null +++ b/ProjectLighthouse.Tests/Unit/TicketTests.cs @@ -0,0 +1,48 @@ +using LBPUnion.ProjectLighthouse.Tickets; +using LBPUnion.ProjectLighthouse.Tickets.Signature; +using LBPUnion.ProjectLighthouse.Types.Users; +using Xunit; + +namespace LBPUnion.ProjectLighthouse.Tests.Unit; + +[Trait("Category", "Unit")] +public class TicketTests +{ + [Fact] + public void CanReadTicket() + { + TicketBuilder builder = new TicketBuilder() + .SetCountry("br") + .SetUserId(21) + .SetDomain("us") + .SetStatus(0) + .SetIssuerId(0x74657374) + .setExpirationTime(ulong.MaxValue) + .SetUsername("unittest") + .SetIssueTime(0) + .SetTitleId("UP9000-BCUS98245_00"); + + byte[] ticketData = builder.Build(); + + NPTicket? ticket = NPTicket.CreateFromBytes(ticketData); + Assert.NotNull(ticket); + Assert.Equal((ulong)0, ticket.IssueTime); + Assert.Equal(ulong.MaxValue, ticket.ExpireTime); + Assert.Equal("unittest", ticket.Username); + Assert.Equal(GameVersion.LittleBigPlanet2, ticket.GameVersion); + Assert.Equal((ulong)0x74657374, ticket.IssuerId); + Assert.Equal((ulong)21, ticket.UserId); + } + + [Fact] + public void CanVerifyTicketSignature() + { + TicketBuilder builder = new(); + + byte[] ticketData = builder.Build(); + + NPTicket? ticket = NPTicket.CreateFromBytes(ticketData); + Assert.NotNull(ticket); + Assert.True(new UnitTestSignatureVerifier(ticket).ValidateSignature()); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/NPTicket.cs b/ProjectLighthouse/Tickets/NPTicket.cs index 3837606d..bb11a93e 100644 --- a/ProjectLighthouse/Tickets/NPTicket.cs +++ b/ProjectLighthouse/Tickets/NPTicket.cs @@ -1,21 +1,14 @@ #nullable enable using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Tickets.Parser; +using LBPUnion.ProjectLighthouse.Tickets.Signature; using LBPUnion.ProjectLighthouse.Types.Logging; using LBPUnion.ProjectLighthouse.Types.Users; -using Org.BouncyCastle.Asn1; -using Org.BouncyCastle.Asn1.X9; -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.Crypto.Parameters; -using Org.BouncyCastle.Math; -using Org.BouncyCastle.Math.EC; -using Org.BouncyCastle.Security; #if DEBUG using System.Text.Json; #endif @@ -27,224 +20,78 @@ namespace LBPUnion.ProjectLighthouse.Tickets; /// public class NPTicket { - public string? Username { get; set; } + public string Username { get; protected internal set; } = ""; - private TicketVersion? ticketVersion { get; set; } - - public Platform Platform { get; set; } + public Platform Platform { get; private set; } + public GameVersion GameVersion { get; private set; } + public string TicketHash { get; private set; } = ""; public uint IssuerId { get; set; } - public ulong IssuedDate { get; set; } - public ulong ExpireDate { get; set; } + public ulong IssueTime { get; set; } + public ulong ExpireTime { get; set; } public ulong UserId { get; set; } - public string TicketHash { get; set; } = ""; - private string? titleId { get; set; } + private TicketVersion? TicketVersion { get; set; } - private byte[] ticketBody { get; set; } = Array.Empty(); + protected internal string TitleId { get; set; } = ""; - private byte[] ticketSignature { get; set; } = Array.Empty(); - private byte[] ticketSignatureIdentifier { get; set; } = Array.Empty(); + private TicketSignatureVerifier SignatureVerifier { get; set; } = new NullSignatureVerifier(); - public GameVersion GameVersion { get; set; } + protected internal SectionHeader BodyHeader { get; set; } - private static ECDomainParameters FromX9EcParams(X9ECParameters param) => - new(param.Curve, param.G, param.N, param.H, param.GetSeed()); + protected internal byte[] Data { get; set; } = Array.Empty(); + protected internal byte[] TicketSignature { get; set; } = Array.Empty(); + protected internal byte[] TicketSignatureIdentifier { get; set; } = Array.Empty(); - public static readonly ECDomainParameters Secp224K1 = FromX9EcParams(ECNamedCurveTable.GetByName("secp224k1")); - public static readonly ECDomainParameters Secp192R1 = FromX9EcParams(ECNamedCurveTable.GetByName("secp192r1")); - - private static readonly ECPoint rpcnPublic = Secp224K1.Curve.CreatePoint( - new BigInteger("b07bc0f0addb97657e9f389039e8d2b9c97dc2a31d3042e7d0479b93", 16), - new BigInteger("d81c42b0abdf6c42191a31e31f93342f8f033bd529c2c57fdb5a0a7d", 16)); - - private static readonly ECPoint psnPublic = Secp192R1.Curve.CreatePoint( - new BigInteger("39c62d061d4ee35c5f3f7531de0af3cf918346526edac727", 16), - new BigInteger("a5d578b55113e612bf1878d4cc939d61a41318403b5bdf86", 16)); - - private static readonly ECPoint unitTestPublic = Secp192R1.Curve.CreatePoint( - new BigInteger("b6f3374bde4ec23a25e1508889e7d7e71870ba74daf8654f", 16), - new BigInteger("738de93dad0fffb5642045439afaaf8c6fda319a72d2a584", 16)); - - internal class SignatureParams + private static bool ReadTicket(NPTicket npTicket, TicketReader reader) { - public string HashAlgo { get; set; } - public ECPoint PublicKey { get; set; } - public ECDomainParameters CurveParams { get; set; } + npTicket.TicketVersion = reader.ReadTicketVersion(); - public SignatureParams(string hashAlgo, ECPoint pubKey, ECDomainParameters curve) - { - this.HashAlgo = hashAlgo; - this.PublicKey = pubKey; - this.CurveParams = curve; - } - } - - private readonly Dictionary signatureParamsMap = new() - { - //psn - { "719F1D4A", new SignatureParams("SHA-1", psnPublic, Secp192R1) }, - //rpcn - { "5250434E", new SignatureParams("SHA-224", rpcnPublic, Secp224K1) }, - //unit test - { "54455354", new SignatureParams("SHA-1", unitTestPublic, Secp192R1) }, - }; - - private bool ValidateSignature() - { - string identifierHex = Convert.ToHexString(this.ticketSignatureIdentifier); - if (!this.signatureParamsMap.ContainsKey(identifierHex)) - { - Logger.Warn($"Unknown signature identifier in ticket: {identifierHex}, platform={this.Platform}", LogArea.Login); - return false; - } - - SignatureParams sigParams = this.signatureParamsMap[identifierHex]; - ECPublicKeyParameters pubKey = new(sigParams.PublicKey, sigParams.CurveParams); - - ISigner signer = SignerUtilities.GetSigner($"{sigParams.HashAlgo}withECDSA"); - signer.Init(false, pubKey); - - signer.BlockUpdate(this.ticketBody); - - return signer.VerifySignature(this.ticketSignature); - } - - // Sometimes psn signatures have one or two extra empty bytes - // This is slow but it's better than carelessly chopping 0's - private static byte[] ParseSignature(byte[] signature) - { - for (int i = 0; i <= 2; i++) - { - try - { - Asn1Object.FromByteArray(signature); - break; - } - catch - { - signature = signature.SkipLast(1).ToArray(); - } - } - - return signature; - } - - private static bool Read21Ticket(NPTicket npTicket, TicketReader reader) - { - reader.ReadTicketString(); // serial id - - npTicket.IssuerId = reader.ReadTicketUInt32(); - npTicket.IssuedDate = reader.ReadTicketUInt64(); - npTicket.ExpireDate = reader.ReadTicketUInt64(); - - npTicket.UserId = reader.ReadTicketUInt64(); - - npTicket.Username = reader.ReadTicketString(); - - reader.ReadTicketString(); // Country - reader.ReadTicketString(); // Domain - - npTicket.titleId = reader.ReadTicketString(); - - reader.ReadTicketUInt32(); // status - - reader.ReadTicketEmpty(); // padding - reader.ReadTicketEmpty(); - - SectionHeader footer = reader.ReadSectionHeader(); // footer header - if (footer.Type != SectionType.Footer) - { - Logger.Warn(@$"Unexpected ticket footer header: expected={SectionType.Footer}, actual={footer}", - LogArea.Login); - return false; - } - - npTicket.ticketSignatureIdentifier = reader.ReadTicketBinary(); - - npTicket.ticketSignature = ParseSignature(reader.ReadTicketBinary()); - return true; - } - - private static bool Read30Ticket(NPTicket npTicket, TicketReader reader) - { - reader.ReadTicketString(); // serial id - - npTicket.IssuerId = reader.ReadTicketUInt32(); - npTicket.IssuedDate = reader.ReadTicketUInt64(); - npTicket.ExpireDate = reader.ReadTicketUInt64(); - - npTicket.UserId = reader.ReadTicketUInt64(); - - npTicket.Username = reader.ReadTicketString(); - - reader.ReadTicketString(); // Country - reader.ReadTicketString(); // Domain - - npTicket.titleId = reader.ReadTicketString(); - - reader.ReadSectionHeader(); // date of birth section - reader.ReadBytes(4); // 4 bytes for year month and day - reader.ReadTicketUInt32(); - - reader.ReadSectionHeader(); // empty section? - reader.ReadTicketEmpty(); - - SectionHeader footer = reader.ReadSectionHeader(); // footer header - if (footer.Type != SectionType.Footer) - { - Logger.Warn(@$"Unexpected ticket footer header: expected={SectionType.Footer}, actual={footer}", - LogArea.Login); - return false; - } - - npTicket.ticketSignatureIdentifier = reader.ReadTicketBinary(); - - npTicket.ticketSignature = ParseSignature(reader.ReadTicketBinary()); - return true; - } - - private static bool ReadTicket(byte[] data, NPTicket npTicket, TicketReader reader) - { - npTicket.ticketVersion = reader.ReadTicketVersion(); - - reader.ReadBytes(4); // Skip header + reader.SkipBytes(4); // Skip header ushort ticketLen = reader.ReadUInt16BE(); + // Subtract 8 bytes to account for ticket header - if (ticketLen != data.Length - 0x8) + if (ticketLen != npTicket.Data.Length - 0x8) { - Logger.Warn(@$"Ticket length mismatch, expected={ticketLen}, actual={data.Length - 0x8}", LogArea.Login); - return false; + throw new TicketParseException( + @$"Ticket length mismatch, expected={ticketLen}, actual={npTicket.Data.Length - 0x8}"); } - long bodyStart = reader.BaseStream.Position; - SectionHeader bodyHeader = reader.ReadSectionHeader(); + npTicket.BodyHeader = reader.ReadSectionHeader(); - if (bodyHeader.Type != SectionType.Body) + if (npTicket.BodyHeader.Type != SectionType.Body) { - Logger.Warn(@$"Unexpected ticket body header: expected={SectionType.Body}, actual={bodyHeader}", LogArea.Login); - return false; + throw new TicketParseException( + @$"Unexpected ticket body header: expected={SectionType.Body}, actual={npTicket.BodyHeader.Type}"); } - Logger.Debug($"bodyHeader.Type is {bodyHeader.Type}, index={bodyStart}", LogArea.Login); + Logger.Debug($"bodyHeader.Type is {npTicket.BodyHeader.Type}, index={npTicket.BodyHeader.Position}", + LogArea.Login); - bool parsedSuccessfully = npTicket.ticketVersion.ToString() switch + ITicketParser ticketParser = npTicket.TicketVersion switch { - "2.1" => Read21Ticket(npTicket, reader), // used by ps3 and rpcs3 - "3.0" => Read30Ticket(npTicket, reader), // used by ps vita + Tickets.TicketVersion.V21 => new TicketParser21(npTicket, reader), // used by ps3 and rpcs3 + Tickets.TicketVersion.V30 => new TicketParser30(npTicket, reader), // used by ps vita _ => throw new NotImplementedException(), }; - if (!parsedSuccessfully) return false; + if (!ticketParser.ParseTicket()) return false; - npTicket.ticketBody = Convert.ToHexString(npTicket.ticketSignatureIdentifier) switch + // Create a uint in big endian + uint signatureIdentifier = (uint)(npTicket.TicketSignatureIdentifier[0] << 24 | + npTicket.TicketSignatureIdentifier[1] << 16 | + npTicket.TicketSignatureIdentifier[2] << 8 | + npTicket.TicketSignatureIdentifier[3]); + + npTicket.SignatureVerifier = signatureIdentifier switch { - // rpcn - "5250434E" => data.AsSpan().Slice((int)bodyStart, bodyHeader.Length + 4).ToArray(), - // psn and unit test - "719F1D4A" or "54455354" => data.AsSpan()[..data.AsSpan().IndexOf(npTicket.ticketSignature)].ToArray(), - _ => throw new ArgumentOutOfRangeException(nameof(npTicket)), + 0x5250434E => new RpcnSignatureVerifier(npTicket), + 0x719F1D4A => new PsnSignatureVerifier(npTicket), + 0x54455354 => new UnitTestSignatureVerifier(npTicket), + _ => throw new ArgumentOutOfRangeException(nameof(npTicket), + signatureIdentifier, + @"Invalid signature identifier"), }; return true; @@ -255,46 +102,60 @@ public class NPTicket /// public static NPTicket? CreateFromBytes(byte[] data) { - NPTicket npTicket = new(); + // Header should have at least 8 bytes + if (data.Length < 8) + { + Logger.Warn("NpTicket does not contain header", LogArea.Login); + return null; + } + NPTicket npTicket = new() + { + Data = data, + }; try { - using MemoryStream ms = new(data); - using TicketReader reader = new(ms); + using TicketReader reader = new(new MemoryStream(data)); - bool validTicket = ReadTicket(data, npTicket, reader); + bool validTicket = ReadTicket(npTicket, reader); if (!validTicket) { Logger.Warn($"Failed to parse ticket from {npTicket.Username}", LogArea.Login); return null; } - if (npTicket.IssuedDate > (ulong)TimeHelper.TimestampMillis) + if (npTicket.IssueTime > (ulong)TimeHelper.TimestampMillis) { - Logger.Warn($"Ticket isn't valid yet from {npTicket.Username} ({npTicket.IssuedDate} > {(ulong)TimeHelper.TimestampMillis})", LogArea.Login); - return null; - } - if ((ulong)TimeHelper.TimestampMillis > npTicket.ExpireDate) - { - Logger.Warn($"Ticket has expired from {npTicket.Username} ({(ulong)TimeHelper.TimestampMillis} > {npTicket.ExpireDate}", LogArea.Login); + Logger.Warn( + $"Ticket isn't valid yet from {npTicket.Username} ({npTicket.IssueTime} > {(ulong)TimeHelper.TimestampMillis})", + LogArea.Login); return null; } - if (npTicket.titleId == null) throw new ArgumentNullException($"{nameof(npTicket)}.{nameof(npTicket.titleId)}"); + if ((ulong)TimeHelper.TimestampMillis > npTicket.ExpireTime) + { + Logger.Warn( + $"Ticket has expired from {npTicket.Username} ({(ulong)TimeHelper.TimestampMillis} > {npTicket.ExpireTime}", + LogArea.Login); + return null; + } + + if (npTicket.TitleId == null) + throw new ArgumentNullException($"{nameof(npTicket)}.{nameof(npTicket.TitleId)}"); // We already read the title id, however we need to do some post-processing to get what we want. // Current data: UP9000-BCUS98245_00 // We need to chop this to get the titleId we're looking for - npTicket.titleId = npTicket.titleId[7..]; // Trim UP9000- - npTicket.titleId = npTicket.titleId[..^3]; // Trim _00 at the end + npTicket.TitleId = npTicket.TitleId[7..]; // Trim UP9000- + npTicket.TitleId = npTicket.TitleId[..^3]; // Trim _00 at the end // Data now (hopefully): BCUS98245 - Logger.Debug($"titleId is {npTicket.titleId}", LogArea.Login); + Logger.Debug($"titleId is {npTicket.TitleId}", LogArea.Login); - npTicket.GameVersion = GameVersionHelper.FromTitleId(npTicket.titleId); // Finally, convert it to GameVersion + npTicket.GameVersion = GameVersionHelper.FromTitleId(npTicket.TitleId); // Finally, convert it to GameVersion if (npTicket.GameVersion == GameVersion.Unknown) { - Logger.Warn($"Could not determine game version from title id {npTicket.titleId}", LogArea.Login); + Logger.Warn($"Could not determine game version from title id {npTicket.TitleId}", LogArea.Login); return null; } @@ -308,15 +169,16 @@ public class NPTicket _ => Platform.Unknown, }; - if (npTicket.Platform == Platform.PS3 && npTicket.GameVersion == GameVersion.LittleBigPlanetVita) npTicket.Platform = Platform.Vita; + if (npTicket.GameVersion == GameVersion.LittleBigPlanetVita) npTicket.Platform = Platform.Vita; - if (npTicket.Platform == Platform.Unknown || (npTicket.Platform == Platform.UnitTest && !ServerStatics.IsUnitTesting)) + if (npTicket.Platform == Platform.Unknown) { Logger.Warn($"Could not determine platform from IssuerId {npTicket.IssuerId} decimal", LogArea.Login); return null; } - if (ServerConfiguration.Instance.Authentication.VerifyTickets && !npTicket.ValidateSignature()) + if (ServerConfiguration.Instance.Authentication.VerifyTickets && + !npTicket.SignatureVerifier.ValidateSignature()) { Logger.Warn($"Failed to verify authenticity of ticket from user {npTicket.Username}", LogArea.Login); return null; @@ -332,12 +194,17 @@ public class NPTicket return npTicket; } + catch (TicketParseException e) + { + Logger.Error($"Parsing npTicket failed: {e.Message}", LogArea.Login); + return null; + } catch(NotImplementedException) { - Logger.Error($"The ticket version {npTicket.ticketVersion} is not implemented yet.", LogArea.Login); + Logger.Error($"The ticket version {npTicket.TicketVersion} is not implemented yet.", LogArea.Login); Logger.Error ( - "Please let us know that this is a ticket version that is actually used on our issue tracker at https://github.com/LBPUnion/project-lighthouse/issues !", + "Please let us know that this is a ticket version that is actually used on our issue tracker at https://github.com/LBPUnion/ProjectLighthouse/issues !", LogArea.Login ); return null; @@ -347,7 +214,7 @@ public class NPTicket Logger.Error("Failed to read npTicket!", LogArea.Login); Logger.Error("Either this is spam data, or the more likely that this is a bug.", LogArea.Login); Logger.Error - ("Please report the following exception to our issue tracker at https://github.com/LBPUnion/project-lighthouse/issues!", LogArea.Login); + ("Please report the following exception to our issue tracker at https://github.com/LBPUnion/ProjectLighthouse/issues!", LogArea.Login); Logger.Error(e.ToDetailedException(), LogArea.Login); return null; } diff --git a/ProjectLighthouse/Tickets/Parser/ITicketParser.cs b/ProjectLighthouse/Tickets/Parser/ITicketParser.cs new file mode 100644 index 00000000..7e600088 --- /dev/null +++ b/ProjectLighthouse/Tickets/Parser/ITicketParser.cs @@ -0,0 +1,6 @@ +namespace LBPUnion.ProjectLighthouse.Tickets.Parser; + +public interface ITicketParser +{ + public bool ParseTicket(); +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Parser/TicketParseException.cs b/ProjectLighthouse/Tickets/Parser/TicketParseException.cs new file mode 100644 index 00000000..dc4857aa --- /dev/null +++ b/ProjectLighthouse/Tickets/Parser/TicketParseException.cs @@ -0,0 +1,13 @@ +using System; + +namespace LBPUnion.ProjectLighthouse.Tickets.Parser; + +public class TicketParseException : Exception +{ + public TicketParseException(string message) + { + this.Message = message; + } + + public override string Message { get; } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Parser/TicketParser21.cs b/ProjectLighthouse/Tickets/Parser/TicketParser21.cs new file mode 100644 index 00000000..216372ef --- /dev/null +++ b/ProjectLighthouse/Tickets/Parser/TicketParser21.cs @@ -0,0 +1,46 @@ +namespace LBPUnion.ProjectLighthouse.Tickets.Parser; + +public class TicketParser21 : ITicketParser +{ + private readonly NPTicket ticket; + private readonly TicketReader reader; + + public TicketParser21(NPTicket ticket, TicketReader reader) + { + this.ticket = ticket; + this.reader = reader; + } + public bool ParseTicket() + { + this.reader.ReadTicketString(); // serial id + + this.ticket.IssuerId = this.reader.ReadTicketUInt32(); + this.ticket.IssueTime = this.reader.ReadTicketUInt64(); + this.ticket.ExpireTime = this.reader.ReadTicketUInt64(); + + this.ticket.UserId = this.reader.ReadTicketUInt64(); + + this.ticket.Username = this.reader.ReadTicketString(); + + this.reader.ReadTicketString(); // Country + this.reader.ReadTicketString(); // Domain + + this.ticket.TitleId = this.reader.ReadTicketString(); + + this.reader.ReadTicketUInt32(); // status + + this.reader.ReadTicketEmpty(); // padding + this.reader.ReadTicketEmpty(); + + SectionHeader footer = this.reader.ReadSectionHeader(); // footer header + if (footer.Type != SectionType.Footer) + { + throw new TicketParseException(@$"Unexpected ticket footer header: expected={SectionType.Footer}, actual={footer}"); + } + + this.ticket.TicketSignatureIdentifier = this.reader.ReadTicketBinary(); + + this.ticket.TicketSignature = this.reader.ReadTicketBinary(); + return true; + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Parser/TicketParser30.cs b/ProjectLighthouse/Tickets/Parser/TicketParser30.cs new file mode 100644 index 00000000..e0437f49 --- /dev/null +++ b/ProjectLighthouse/Tickets/Parser/TicketParser30.cs @@ -0,0 +1,49 @@ +namespace LBPUnion.ProjectLighthouse.Tickets.Parser; + +public class TicketParser30 : ITicketParser +{ + private readonly NPTicket ticket; + private readonly TicketReader reader; + + public TicketParser30(NPTicket ticket, TicketReader reader) + { + this.ticket = ticket; + this.reader = reader; + } + + public bool ParseTicket() + { + this.reader.ReadTicketString(); // serial id + + this.ticket.IssuerId = this.reader.ReadTicketUInt32(); + this.ticket.IssueTime = this.reader.ReadTicketUInt64(); + this.ticket.ExpireTime = this.reader.ReadTicketUInt64(); + + this.ticket.UserId = this.reader.ReadTicketUInt64(); + + this.ticket.Username = this.reader.ReadTicketString(); + + this.reader.ReadTicketString(); // Country + this.reader.ReadTicketString(); // Domain + + this.ticket.TitleId = this.reader.ReadTicketString(); + + this.reader.ReadSectionHeader(); // date of birth section + this.reader.ReadBytes(4); // 4 bytes for year month and day + this.reader.ReadTicketUInt32(); + + this.reader.ReadSectionHeader(); // empty section? + this.reader.ReadTicketEmpty(); + + SectionHeader footer = this.reader.ReadSectionHeader(); // footer header + if (footer.Type != SectionType.Footer) + { + throw new TicketParseException(@$"Unexpected ticket footer header: expected={SectionType.Footer}, actual={footer}"); + } + + this.ticket.TicketSignatureIdentifier = this.reader.ReadTicketBinary(); + + this.ticket.TicketSignature = this.reader.ReadTicketBinary(); + return true; + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Signature/NullSignatureVerifier.cs b/ProjectLighthouse/Tickets/Signature/NullSignatureVerifier.cs new file mode 100644 index 00000000..7c1a0b8d --- /dev/null +++ b/ProjectLighthouse/Tickets/Signature/NullSignatureVerifier.cs @@ -0,0 +1,11 @@ +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; + +namespace LBPUnion.ProjectLighthouse.Tickets.Signature; + +public class NullSignatureVerifier : TicketSignatureVerifier +{ + protected override ECPublicKeyParameters PublicKey => null; + protected override string HashAlgorithm => null; + protected override bool VerifySignature(ISigner signer) => false; +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Signature/PsnSignatureVerifier.cs b/ProjectLighthouse/Tickets/Signature/PsnSignatureVerifier.cs new file mode 100644 index 00000000..0961de6a --- /dev/null +++ b/ProjectLighthouse/Tickets/Signature/PsnSignatureVerifier.cs @@ -0,0 +1,39 @@ +using System; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Math.EC; + +namespace LBPUnion.ProjectLighthouse.Tickets.Signature; + +public class PsnSignatureVerifier : TicketSignatureVerifier +{ + private static readonly Lazy publicKeyParams = new(() => + { + ECDomainParameters curve = FromX9EcParams(ECNamedCurveTable.GetByName("secp192r1")); + ECPoint publicKey = curve.Curve.CreatePoint( + new BigInteger("39c62d061d4ee35c5f3f7531de0af3cf918346526edac727", 16), + new BigInteger("a5d578b55113e612bf1878d4cc939d61a41318403b5bdf86", 16)); + return new ECPublicKeyParameters(publicKey, curve); + }); + + protected override ECPublicKeyParameters PublicKey => publicKeyParams.Value; + protected override string HashAlgorithm => "SHA-1"; + + private readonly NPTicket ticket; + + public PsnSignatureVerifier(NPTicket ticket) + { + this.ticket = ticket; + } + + protected override bool VerifySignature(ISigner signer) + { + Span ticketBody = this.ticket.Data.AsSpan(); + ticketBody = ticketBody[..ticketBody.IndexOf(this.ticket.TicketSignature)]; + signer.BlockUpdate(ticketBody); + + return signer.VerifySignature(TrimSignature(this.ticket.TicketSignature)); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Signature/RpcnSignatureVerifier.cs b/ProjectLighthouse/Tickets/Signature/RpcnSignatureVerifier.cs new file mode 100644 index 00000000..b6a7df94 --- /dev/null +++ b/ProjectLighthouse/Tickets/Signature/RpcnSignatureVerifier.cs @@ -0,0 +1,39 @@ +using System; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Math.EC; + +namespace LBPUnion.ProjectLighthouse.Tickets.Signature; + +public class RpcnSignatureVerifier : TicketSignatureVerifier +{ + private static readonly Lazy publicKeyParams = new(() => + { + ECDomainParameters curve = FromX9EcParams(ECNamedCurveTable.GetByName("secp224k1")); + ECPoint publicKey = curve.Curve.CreatePoint( + new BigInteger("b07bc0f0addb97657e9f389039e8d2b9c97dc2a31d3042e7d0479b93", 16), + new BigInteger("d81c42b0abdf6c42191a31e31f93342f8f033bd529c2c57fdb5a0a7d", 16)); + return new ECPublicKeyParameters(publicKey, curve); + }); + + protected override ECPublicKeyParameters PublicKey => publicKeyParams.Value; + protected override string HashAlgorithm => "SHA-224"; + + private readonly NPTicket ticket; + + public RpcnSignatureVerifier(NPTicket ticket) + { + this.ticket = ticket; + } + + protected override bool VerifySignature(ISigner signer) + { + Span ticketBody = this.ticket.Data.AsSpan(); + ticketBody = ticketBody.Slice(this.ticket.BodyHeader.Position, this.ticket.BodyHeader.Length + 4); + signer.BlockUpdate(ticketBody); + + return signer.VerifySignature(TrimSignature(this.ticket.TicketSignature)); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Signature/TicketSignatureVerifier.cs b/ProjectLighthouse/Tickets/Signature/TicketSignatureVerifier.cs new file mode 100644 index 00000000..b9ffe9ce --- /dev/null +++ b/ProjectLighthouse/Tickets/Signature/TicketSignatureVerifier.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; + +namespace LBPUnion.ProjectLighthouse.Tickets.Signature; + +public abstract class TicketSignatureVerifier +{ + protected abstract ECPublicKeyParameters PublicKey { get; } + protected abstract string HashAlgorithm { get; } + + protected abstract bool VerifySignature(ISigner signer); + + public bool ValidateSignature() + { + ISigner signer = SignerUtilities.GetSigner($"{this.HashAlgorithm}withECDSA"); + signer.Init(false, this.PublicKey); + + return this.VerifySignature(signer); + } + + protected static ECDomainParameters FromX9EcParams(X9ECParameters param) => + new(param.Curve, param.G, param.N, param.H, param.GetSeed()); + + // Sometimes psn signatures have one or two extra empty bytes + // This is slow but it's better than carelessly chopping 0's + private protected static byte[] TrimSignature(byte[] signature) + { + for (int i = 0; i <= 2; i++) + { + try + { + Asn1Object.FromByteArray(signature); + break; + } + catch + { + signature = signature.SkipLast(1).ToArray(); + } + } + + return signature; + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/Signature/UnitTestSignatureVerifier.cs b/ProjectLighthouse/Tickets/Signature/UnitTestSignatureVerifier.cs new file mode 100644 index 00000000..f6300d13 --- /dev/null +++ b/ProjectLighthouse/Tickets/Signature/UnitTestSignatureVerifier.cs @@ -0,0 +1,43 @@ +using System; +using LBPUnion.ProjectLighthouse.Configuration; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Math.EC; + +namespace LBPUnion.ProjectLighthouse.Tickets.Signature; + +public class UnitTestSignatureVerifier : TicketSignatureVerifier +{ + protected internal static readonly Lazy PublicKeyParams = new(() => + { + ECDomainParameters curve = FromX9EcParams(ECNamedCurveTable.GetByName("secp192r1")); + ECPoint publicKey = curve.Curve.CreatePoint( + new BigInteger("b6f3374bde4ec23a25e1508889e7d7e71870ba74daf8654f", 16), + new BigInteger("738de93dad0fffb5642045439afaaf8c6fda319a72d2a584", 16)); + return new ECPublicKeyParameters(publicKey, curve); + }); + + protected override ECPublicKeyParameters PublicKey => PublicKeyParams.Value; + protected override string HashAlgorithm => "SHA-1"; + + private readonly NPTicket ticket; + + public UnitTestSignatureVerifier(NPTicket ticket) + { + this.ticket = ticket; + } + + protected override bool VerifySignature(ISigner signer) + { + // Don't allow tickets signed by the unit testing private key + if (!ServerStatics.IsUnitTesting) return false; + + Span ticketBody = this.ticket.Data.AsSpan(); + ticketBody = ticketBody[..ticketBody.IndexOf(this.ticket.TicketSignature)]; + signer.BlockUpdate(ticketBody); + + return signer.VerifySignature(TrimSignature(this.ticket.TicketSignature)); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/TicketBuilder.cs b/ProjectLighthouse/Tickets/TicketBuilder.cs index d534e1c6..8f500360 100644 --- a/ProjectLighthouse/Tickets/TicketBuilder.cs +++ b/ProjectLighthouse/Tickets/TicketBuilder.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text; using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Tickets.Signature; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; @@ -92,7 +93,7 @@ public class TicketBuilder private static byte[] SignData(byte[] data) { - ECPrivateKeyParameters key = new(privateKey, NPTicket.Secp192R1); + ECPrivateKeyParameters key = new(privateKey, UnitTestSignatureVerifier.PublicKeyParams.Value.Parameters); ISigner signer = SignerUtilities.GetSigner("SHA-1withECDSA"); signer.Init(true, key); @@ -126,7 +127,7 @@ public class TicketBuilder return this; } - public TicketBuilder SetIssuerId(ushort issuerId) + public TicketBuilder SetIssuerId(uint issuerId) { this.IssuerId = issuerId; return this; @@ -155,5 +156,4 @@ public class TicketBuilder this.Status = status; return this; } - } \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/TicketReader.cs b/ProjectLighthouse/Tickets/TicketReader.cs index b2a4dde9..ee6b1a70 100644 --- a/ProjectLighthouse/Tickets/TicketReader.cs +++ b/ProjectLighthouse/Tickets/TicketReader.cs @@ -32,6 +32,7 @@ public struct SectionHeader { public SectionType Type; public ushort Length; + public int Position; } public class TicketReader : BinaryReader @@ -39,7 +40,9 @@ public class TicketReader : BinaryReader public TicketReader([NotNull] Stream input) : base(input) {} - public TicketVersion ReadTicketVersion() => new((byte)(this.ReadByte() >> 4), this.ReadByte()); + public TicketVersion ReadTicketVersion() => (TicketVersion)this.ReadUInt16BE(); + + public void SkipBytes(long bytes) => this.BaseStream.Position += bytes; public SectionHeader ReadSectionHeader() { @@ -49,6 +52,7 @@ public class TicketReader : BinaryReader { Type = (SectionType)this.ReadByte(), Length = this.ReadUInt16BE(), + Position = (int)(this.BaseStream.Position - 4), }; return sectionHeader; diff --git a/ProjectLighthouse/Tickets/TicketTypes.cs b/ProjectLighthouse/Tickets/TicketTypes.cs index 36f008fb..419eb588 100644 --- a/ProjectLighthouse/Tickets/TicketTypes.cs +++ b/ProjectLighthouse/Tickets/TicketTypes.cs @@ -8,7 +8,7 @@ namespace LBPUnion.ProjectLighthouse.Tickets; public abstract class TicketData { - public void WriteHeader(BinaryWriter writer) + protected void WriteHeader(BinaryWriter writer) { byte[] id = new byte[2]; byte[] len = new byte[2]; @@ -19,7 +19,7 @@ public abstract class TicketData } public abstract void Write(BinaryWriter writer); - public abstract short Id(); + protected abstract short Id(); public abstract short Len(); } @@ -31,7 +31,7 @@ public class EmptyData : TicketData this.WriteHeader(writer); } - public override short Id() => 0; + protected override short Id() => 0; public override short Len() => 0; } #endregion @@ -54,7 +54,7 @@ public class UInt32Data : TicketData writer.Write(data); } - public override short Id() => 1; + protected override short Id() => 1; public override short Len() => 4; } #endregion @@ -77,7 +77,7 @@ public class UInt64Data : TicketData writer.Write(data); } - public override short Id() => 2; + protected override short Id() => 2; public override short Len() => 8; } #endregion @@ -103,7 +103,7 @@ public class StringData : TicketData writer.Write(this.val); } - public override short Id() => 4; + protected override short Id() => 4; public override short Len() => (short)this.val.Length; } #endregion @@ -126,7 +126,7 @@ public class TimestampData : TicketData writer.Write(data); } - public override short Id() => 7; + protected override short Id() => 7; public override short Len() => 8; } #endregion @@ -147,7 +147,7 @@ public class BinaryData : TicketData writer.Write(this.val); } - public override short Id() => 8; + protected override short Id() => 8; public override short Len() => (short)this.val.Length; } #endregion @@ -173,7 +173,7 @@ public class BlobData : TicketData } } - public override short Id() => (short)(0x3000 | this.id); + protected override short Id() => (short)(0x3000 | this.id); public override short Len() => (short)this.data.Sum(d => d.Len() + 4); } #endregion \ No newline at end of file diff --git a/ProjectLighthouse/Tickets/TicketVersion.cs b/ProjectLighthouse/Tickets/TicketVersion.cs index 120b8562..2e1659b7 100644 --- a/ProjectLighthouse/Tickets/TicketVersion.cs +++ b/ProjectLighthouse/Tickets/TicketVersion.cs @@ -1,17 +1,7 @@ namespace LBPUnion.ProjectLighthouse.Tickets; -public struct TicketVersion +public enum TicketVersion : ushort { - public byte Major { get; set; } - public byte Minor { get; set; } - - public TicketVersion(byte major, byte minor) - { - this.Major = major; - this.Minor = minor; - } - - public override string ToString() => $"{this.Major}.{this.Minor}"; - - public static implicit operator string(TicketVersion v) => v.ToString(); + V21 = 0x2101, + V30 = 0x3100, } \ No newline at end of file