mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-04-19 19:14:51 +00:00
Refactor NpTicket parser and add NpTicket signature documentation (#784)
* Refactor NpTicket parser and add NpTicket signature documentation * Update TickerBuilder chaining style * Fix SectionHeader position returning wrong value * Don't attempt to parse ticket if it is less than 8 bytes
This commit is contained in:
parent
8b0aed9a61
commit
2f11731a8e
16 changed files with 506 additions and 247 deletions
57
Documentation/Tickets.md
Normal file
57
Documentation/Tickets.md
Normal file
|
@ -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
|
||||
```
|
48
ProjectLighthouse.Tests/Unit/TicketTests.cs
Normal file
48
ProjectLighthouse.Tests/Unit/TicketTests.cs
Normal file
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
|||
/// </summary>
|
||||
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<byte>();
|
||||
protected internal string TitleId { get; set; } = "";
|
||||
|
||||
private byte[] ticketSignature { get; set; } = Array.Empty<byte>();
|
||||
private byte[] ticketSignatureIdentifier { get; set; } = Array.Empty<byte>();
|
||||
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<byte>();
|
||||
protected internal byte[] TicketSignature { get; set; } = Array.Empty<byte>();
|
||||
protected internal byte[] TicketSignatureIdentifier { get; set; } = Array.Empty<byte>();
|
||||
|
||||
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<string, SignatureParams> 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
|
|||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
|
6
ProjectLighthouse/Tickets/Parser/ITicketParser.cs
Normal file
6
ProjectLighthouse/Tickets/Parser/ITicketParser.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace LBPUnion.ProjectLighthouse.Tickets.Parser;
|
||||
|
||||
public interface ITicketParser
|
||||
{
|
||||
public bool ParseTicket();
|
||||
}
|
13
ProjectLighthouse/Tickets/Parser/TicketParseException.cs
Normal file
13
ProjectLighthouse/Tickets/Parser/TicketParseException.cs
Normal file
|
@ -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; }
|
||||
}
|
46
ProjectLighthouse/Tickets/Parser/TicketParser21.cs
Normal file
46
ProjectLighthouse/Tickets/Parser/TicketParser21.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
49
ProjectLighthouse/Tickets/Parser/TicketParser30.cs
Normal file
49
ProjectLighthouse/Tickets/Parser/TicketParser30.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
11
ProjectLighthouse/Tickets/Signature/NullSignatureVerifier.cs
Normal file
11
ProjectLighthouse/Tickets/Signature/NullSignatureVerifier.cs
Normal file
|
@ -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;
|
||||
}
|
39
ProjectLighthouse/Tickets/Signature/PsnSignatureVerifier.cs
Normal file
39
ProjectLighthouse/Tickets/Signature/PsnSignatureVerifier.cs
Normal file
|
@ -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<ECPublicKeyParameters> 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<byte> ticketBody = this.ticket.Data.AsSpan();
|
||||
ticketBody = ticketBody[..ticketBody.IndexOf(this.ticket.TicketSignature)];
|
||||
signer.BlockUpdate(ticketBody);
|
||||
|
||||
return signer.VerifySignature(TrimSignature(this.ticket.TicketSignature));
|
||||
}
|
||||
}
|
39
ProjectLighthouse/Tickets/Signature/RpcnSignatureVerifier.cs
Normal file
39
ProjectLighthouse/Tickets/Signature/RpcnSignatureVerifier.cs
Normal file
|
@ -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<ECPublicKeyParameters> 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<byte> 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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<ECPublicKeyParameters> 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<byte> ticketBody = this.ticket.Data.AsSpan();
|
||||
ticketBody = ticketBody[..ticketBody.IndexOf(this.ticket.TicketSignature)];
|
||||
signer.BlockUpdate(ticketBody);
|
||||
|
||||
return signer.VerifySignature(TrimSignature(this.ticket.TicketSignature));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
}
|
Loading…
Add table
Reference in a new issue