Building a simple NTP client with Powershell

2024-10-12

Have you ever thougth about using acurate time in a PowerShell script? I have written a simple NTP Client in PowerShell so that I can query any NTP Server.
The motivation was not to have a NTP Client for testing NTP Server connections or getting the current Date and Time for simply using it in a script. But this was the postive side effect of this.

Building a simple NTP Client with Powershell

Why I have decided to built a NTP Client in Powershell

Everything started with a series of failed operating system deployment (OSD) task sequences (TS) executed withe PXE from a MECM/SCCM. The first part of the deployment was ok but after Windows was starting up the TS fails.
After checking a lot of log files and do some TS debbuging. I found the problems:

  1. The certificate for SCCM-Client communication was not valid because the issuing date was in the future or the expiration date was overdue.
  2. Domain join faild

After checking the Computers date and time it turns out, that it was either up to 10 months in the future or in the past.

So I have tried some methods to get the time from the PXE server during WinPE phase but without any luck.
Now I have decided to make the PXE server also a NTP server and use a NTP Client to get the correct time from the PXE server to set the system clock to the current date and time.

After a little research I decided to write a easy NTP client in Powershell tha can work in WinPE.

The NTP data packet

The NTP data packet is 48 Byte long and stores some information in bit fields. The first 32 bits stores following information

  • LI (Leap Indicator): 2 bits witch can represent following status:

  • 00b -> 0d -> no warning

  • 01b -> 1d -> last minute has 61 seconds

  • 10b -> 2d -> last minute has 59 seconds

  • 11b -> 3d -> alarm condition (clock not syncronized)

  • VN (Version Number) 3 bits representing the version of NTP used by the client in the request packet and the server version in the response packet.

  • Mode: 3 bits representing the NTP Mode

  • 0: reserved -> 000b

  • 1: symetric active -> 001b

  • 2: symetric passive -> 010b

  • 3: client mode -> 011b

  • 4: server mode -> 100b

  • 5: broadcast mode -> 101b

  • 6: reserved for NTP control message -> 110b

  • 7: reserved for private use -> 111b

  • Stratum: 8 bits for the stratum level of the clock. Stratum 1 clock has the higest precision stratum 15 has the lowest precision

  • 0: Unspecified or invalid 00000000b

  • 1: Primary server 00000001b

  • 2–15: Secondary server 00000010b - 00001111b

  • 16: Unsynchronized 00010000b

  • 17–255: Reserved 00010001b - 11111111b

  • Pol: 8 bit signed integer: Maximum intervall between successive messages in seconds

  • Precision: 8 bit signed integer: Precision of the local clock

After this header information follows the following information:

  • Root Dealy: The total round-trip delay from the server to the primary reference source. The value is a 32-bit signed fixed-point number in units of seconds, with the fraction point between bits 15 and 16. This field is significant only in server messages.
  • Root Dispersion: The maximum error due to clock frequency tolerance. The value is a 32-bit signed fixed-point number in units of seconds, with the fraction point between bits 15 and 16. This field is significant only in server messages.
  • Reference Identifier: For stratum 1 servers this value is a four-character ASCII code that describes the external reference source (refer to Figure 2). For secondary servers this value is the 32-bit IPv4 address of the synchronization source, or the first 32 bits of the Message Digest Algorithm 5 (MD5) hash of the IPv6 address of the synchronization source.
  • Reference Timestamp: 64 bit: Local time at which the local clock was last set or corrected. If the clock never syncronized with a tim esource the value should 0
  • Originate Timestamp: 64 bit : Local time at which the requesting NTP packet departed the client for the server
  • Receive Timestamp: 64 bit local server time at which the NTP packet arived the server
  • Transmit Timestamp: 64 bit: Local server time at which the NTP packet departed the server for the client

Now we have the total amount of 384 bits or 48 bytes 8 bit each.

For NTPv4 there are aditional but optional 96 bits = 12 bytes 8 bit each for Authentication information

How to build the request packet

Before we can request the time information from a NTP server we need a request packet in the format, explained above.
For a very simple NTP client only following informations are necessary:

  • Leap indicator
  • Version Number
  • NTP Mode

But anyway you have to send a request packet of 48 bytes, therefore we define a variable as a byte array with a length of 48

$ntpData = New-Object byte[] 48

Now we have to set the required header information, this is only the first byte.
The Leap Indicator is 0 so we can ignore it.
As NTP version we use 3.
The NTP mode have to be 3 (Client Mode)
The structure of the first header byte is:

LI Version Mode
Worth of bits within field
2 1 4 2 1 4 2 1
Worth of bits within byte
128 64 32 16 8 4 2 1
0 0 0 1 1 0 1 1

To place the version number at the right position in the header byte we use 3 and shift it left by 3 bits.

128 64 32 16 8 4 2 1
0 0 0 0 0 0 1 1
shift left by 3 bits
0 0 0 1 1 0 0 0

Now we can combine it with the value for the NTP mode by doing a binary or with 3.

128 64 32 16 8 4 2 1
0 0 0 1 1 0 0 0
binary or
0 0 0 0 0 0 1 1
=
0 0 0 1 1 0 1 1

The complete header has the following format:

Precision Poll Stratum LI Version Mode
Worth of bits within field
128 64 32 16 8 4 2 1 128 64 32 16 8 4 2 1 128 64 32 16 8 4 2 1 2 1 4 2 1 4 2 1
Worth of bits within header
231 230 229 228 227 226 225 224 223 222 221 220 219 218 217 216 215 214 213 212 211 210 29 28 27 26 25 24 23 22 21 20
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1 1

if the Leap Indicator have a other value than 0 we must do a shift left by 6 bits before we add it with a binary or to the header byte. In powershell this looks like follows:

[byte]$LI=0
[byte]$NTPVersion=3
[byte]$NTPMode=3
$ntpData[0]=($LI -shl 6) -bor ($NTPVersion -shl 3) -bor $NTPMode

The value of the header byte is now 27d or 1Bh therefore the packet is ready for transmssion.
To do that we need to know the FQDN or the IP-Address of a NTP Server and the port where the NTP-Server is listening.
The NTP Default port is 123 UDP.

Connect to the NTP server, sending the request and getting the result

Now its time to setup the connection. We do this using the .NET classes

  • System.Net.Dns
  • System.Net.IPEndPoint
  • System.Net.Sockets.Socket

and by the enums

  • System.Net.Sockets.AddressFamily
  • System.Net.Sockets.SocketType
  • System.Net.Sockets.ProtocolType

The System.Net.Dns class is used to resolve the FQDN of a NTP server to a address object.

$ntpServer="pool.ntp.org"
$addresses = [System.Net.Dns]::GetHostEntry($ntpServer).AddressList;

Next step is to create a System.Net.IPEndPoint object, therfore we will use the first address object in the $addresses object list.

#The UDP port number assigned to NTP is 123
#the constructor for IPEndPoint needs the address object ant the port number
$ipEndPoint = new-object System.Net.IPEndpoint($addresses[0], 123)

Now we are ready to connect to the NTP server. For this we need a network socket connection.

To do this we use the System.Net.Sockets.Socket class and using as parameters for the constructor:

  • the address family InterNetwork
  • the socket type is dgram (datagram for "connection less" communications)
  • the protocol type UDP

For specifying these values we will use the enumeration classes that contain this information.

#NTP uses UDP
$socket = New-Object System.Net.Sockets.Socket([System.Net.Sockets.AddressFamily]::InterNetwork,
[System.Net.Sockets.SocketType]::Dgram, [System.Net.Sockets.ProtocolType]::Udp)
$socket.Connect($ipEndPoint);

To prevent frozen connections we set a send and recive timeout of 3000 milliseconds then we send the request packet and try to recive the answer packet. Then we close the connection.

#Stops code hang if NTP is blocked
$socket.SendTimeout=3000
$socket.ReceiveTimeout = 3000
[int32]$BytesSentReceived = $socket.Send($ntpData)
$BytesSentReceived = $socket.Receive($ntpData)
$socket.Close()

Getting required information from the answer packet

Now it is time for the funny part. We have all data we need but for using them we must extract it from the recived packet and converting it to a usable format. For doing this we specifies index variables containing the starting index in the byte array for our values. This is only for more transparency.

[byte[]]$IdxReferenceIdentifier = 12, 13, 14, 15 #Powershell allowes it to use multiple indices to get informations from
a array
[byte] $IdxRootDelay = 4;
[byte] $IdxRootDispersion = 8;
[byte] $IdxReferenceTimestamp = 16;
[byte] $IdxOriginatinTimestamp = 24;
[byte] $IdxReceiveTimestamp = 32;
[byte] $IdxServerReplyTime = 40;
Getting header information

First we get the header informations out of our answer packet. For this we uses the System.BitConverter class. Remember the header has 32 bit. Therefore we convert the first 4 bytes to a unsigned 32bit integer

[uint32]$Header = [System.BitConverter]::ToUInt32($ntpData, 0)

Next step is to get the reqired informations to do this we use binary operators for getting the Leap Indicator we first do a shift right to the value in the header and then do binary and with 3d this preserves the value of the first 2 bits but set all other bits to 0

[byte]$leap = $Header -shr 6 -band 3

Now we get the NTP server version by do a shift right by 3 bits and than do a binary and with 7d (111b) to only get the value of the first 3 bits an set all other bits to 0

[byte]$ServerVersion = $Header -shr 3 -band 7

For getting the NTP mode we just do a binary and to the header field with the value 7d (111b)

[byte]$NTPMode = $Header -band 7

Getting the stratum value of the NTP server is done by shifting right the header value by 8 bits and do a binary and with 255d (11111111b / ffh)

[byte]$stratum = $Header -shr 8 -band 255

Getting the right value vor the Poll is a littel bit more tricky because it is a 8 bit signed integer.
A 8 bit signed integer have a maximum value of 127d 0111111b and a minimum value of -128d 10000000b this is because tnegative values are stored inverted and the higest bit (128) has a dual function: if it is set, the value is negative and it represents the value of -128. so the bit 7 hase the value of 64d and if it is set it will be added to -128d this means in math -128 + 64 = -64
Therefore the (signed 8 bit integer) binary value of 11111111b is the decimal value -1 = -128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = -1

Ok, first we get the value as a unsigned 8 bit integer out of the header by do a shift rigt by 16 bit and a binary and with 255d

[byte]$upoll = $header -shr 16 -band 255

now we check if the higest bit is set or not. This is done with a binary and with 128d if so, we do a binary and with the value of 127 and stores the result in a signed byte.
After this we have two options:

  1. do a binary or of our signed byte with -128
  2. use a signed byte with the value 1 and shift this 7 bits to the left and then do a binary or with our first signed

byte

if(($upoll -band 128) -eq 128 ){
[sbyte]$poll = $upoll -band 127
$poll = $poll -bor ([sbyte]-128)
} else {
[sbyte]$poll = $upoll
}

second option

if(($upoll -band 128) -eq 128 ){
[sbyte]$poll = $upoll -band 127
$poll = $poll -bor ([sbyte]1 -shl 7)
} else {
[sbyte]$poll = $upoll
}

alternatively, you can write it like this:

[sbyte]$poll = $upoll -band 127
if(($upoll -band 128) -eq 128 ){
$poll = $poll -bor ([sbyte]-128)
}

this will work because the higest bit will never set to 1 if the value is positive We get the value for the precision in the same way

[byte]$uPrecision = $header -shr 24 -band 255
[sbyte]$Precision = $uPrecision -band 127
if(($uPrecision -band 128) -eq 128 ){
$Precision = $Precision -bor ([sbyte]-128)
}

Now we have all information from the header.

For the other informations in the packet we have to consider, that they are in "Network Byte Order" also knowen as "Big Endian".
Therefor we must switch the byte order to Little Endian (the Intel CPU normal byte order) first. To do this we will use two little functions SwapEndianes and SwapEndianness64. This two functions should only demonstrate the process.
Later we will only use one function SwapEndianness where we can pass either a uint32 or a uint64.
The function than swaps the first with the last byte, the second with the penultimate byte. If the value was a uint32 we are finished now if it was a uint64 this process goes on...
Swaping the third with third to last byte and so on until the byte order is changed

function SwapEndianness64{
param(
[uint64]$x
)
[uint64]$swapmask = 0xff
return ([uint64]((($x -band $swapmask) -shl 56) -bor (($x -band ($swapmask -shl 8)) -shl 40) -bor (($x -band ($swapmask
-shl 16)) -shl 24) -bor (($x -band ($swapmask -shl 24)) -shl 8) -bor (($x -shr 56) -band ($swapmask )) -bor (($x -shr
40) -band ($swapmask -shl 8)) -bor ($x -shr 24 -band ($swapmask -shl 16)) -bor (($x -shr 8 -band ($swapmask -shl 24))))
)
}

function SwapEndiannesss{
param(
[uint32]$x
)
[uint32]$swapmask = 0xff
return ([uint32]((($x -band ($swapmask -shl 16)) -shl 24) -bor (($x -band ($swapmask -shl 24)) -shl 8) -bor (($x -shr
56) -band ($swapmask )) -bor (($x -shr 40) -band ($swapmask -shl 8)) -bor ($x -shr 24 -band ($swapmask -shl 16)) -bor
(($x -shr 8 -band ($swapmask -shl 24))))
)

}

Now its time to get the value for the root delay the problem here is, that this value is a signed fixpoint number with the decimal point between bit 15 and 16 but it is stored as 32 bit unsigned integer. because of this we have to do following steps:
Get the root delay field from the NTP response packet, change the byte order to little endian and convert the unsigned int into a floating point value.
This sounds complicated but it isn't.
Instead of bit operations we use the System.BitConverter class to convert the unsigned int32 into a signed int32.

To do this we convert the unsigned integer into a byte array and then back to a signed integer.
After this we convert the now signed integer into a floatin gpoint number with single precison. This is done by casting the integer to single and than dividing it by the value that we get if we shift 1 to the left by the number of bits are used by the decimal part.
In this case the decimal point is beween bit 15 and 16 so we shift 1 to the left by 16 bits

[uint32]$RootDelay = [System.BitConverter]::ToUInt32($ntpData, $IdxRootDelay)
[uint32]$TempRootDelay = SwapEndianness -SwapValue $RootDelay
[int32]$TempRootDelaySigned=[System.BitConverter]::ToInt32(([System.BitConverter]::GetBytes($TempRootDelay)),0)
[single]$fRootDelay = ([Single]$TempRootDelaySigned) / (1 -shl 16)

For the sake of completeness here we uses the solution using bit operations for the root disperion:

[uint32]$RootDispersion = SwapEndianness -SwapValue ([System.BitConverter]::ToUInt32($ntpData, $IdxRootDispersion))
[int32]$RootDispersionSigned = $RootDispersion -band (-bnot ([uint32]1 -shl 31 ))
if($RootDispersion -band ([uint32]1 -shl 31 )){
$RootDispersionSigned = $RootDispersionSigned -bor ([int32]1 -shl 31)
}
[single]$fRootDispersion = ([single]$RootDispersionSigned) / (1 -shl 16)

Next we will need the value vor the reference identifier. The reference identifier is special, because it stores different information types based on the startum level of the server. If the Server is stratum 1 the reference identifier provides a the 4 char identifier of the reference clock in ASCII code.
If the server is stratum 2 or higher, the reference identifier stores the IPv4 address of the next higher NTP-server in the hirarchie.
For stratum 1 / 0 following referenz identifiers are typical

Code External Reference Source
LOCL uncalibrated local clock used as a primary reference for a subnet without external means of synchronization
PPS atomic clock or other pulse-per-second source individually calibrated to national standards
ACTS NIST dialup modem service
USNO USNO modem service
PTB PTB (Germany) modem service. There can a number after the PTB
TDF Allouis (France) Radio 164 kHz
DCF Mainflingen (Germany) Radio 77.5 kHz
MSF Rugby (UK) Radio 60 kHz
WWV Ft. Collins (US) Radio 2.5, 5, 10, 15, 20 MHz
WWVB Boulder (US) Radio 60 kHz
WWVH Kaui Hawaii (US) Radio 2.5, 5, 10, 15 MHz
CHU Ottawa (Canada) Radio 3330, 7335, 14670 kHz
LORC LORAN-C radionavigation system
OMEG OMEGA radionavigation system
GPS Global Positioning Service
GOES Geostationary Orbit Environment Satellite

Different to the other fields, we can get the values directly from the bytes. We owe this to the fact, that Powershell allowes to use multiple indexes at the same time on a array. Or in other words, we can use a array of indexes to access the content of a array in any order we want.
Therefore the $IdxReferenceIdentifier variable contains all 4 indexes we need to access the bytes. Therefore we get a byte array as result with exactly 4 entries.
In addition to this fact, the ipaddress class stores IP-addresses in Big-Endian.

Now the only thing we have to do is to create a encoder for ASCII and than check the stratum level of the NTP server.

$enc = [System.Text.Encoding]::ASCII
if($stratum -eq 1){
[string]$ReferenceIdentifier = $enc.GetString($ntpData[$IdxReferenceIdentifier])
}
else {
[ipaddress]$ReferenceIdentifier = [ipaddress]::new($ntpData[$IdxReferenceIdentifier])
}

In the next step we get the time stamps out of the recived packet. Each is 8 byte or 64 bit and contains the elapsed time in seconds from 1900-01-01 until the moment the timestamp was generated.
To do this, we use the System.BitConverter class and read the timestamps as 64 bit unsigned integer.

[uint64]$ReferenceTimestamp = [System.BitConverter]::ToUInt64($ntpData, $IdxReferenceTimestamp)
[uint64]$OriginateTimestamp = [System.BitConverter]::ToUInt64($ntpData, $IdxOriginatinTimestamp)
[uint64]$ReceiveTimestamp = [System.BitConverter]::ToUInt64($ntpData, $IdxReceiveTimestamp)
[uint64]$ServerReplayTime = [System.BitConverter]::ToUInt64($ntpData, $IdxserverReplyTime)

To convert this value now to a date time value we need it in milliseconds and we must consider, that this timestamp value contains a 32 bit integer value and a 32 bit fractional part.
So first we define two binary masks, one for the integer part and one for the fractional part.

[uint64]$IntPartMask = ([uint64]( -bnot [uint32]0 )) -shl 32 #Workaround for using constant 0xffffffff00000000 cause
suffix u or ul only exists since Powershell 6.2
[uint64]$FractPartMask = ([uint64]( -bnot [uint32]0 ))# sufix l (lower cas L) exists in PS 5 but for consistency I will
use the same workaround for 0x00000000ffffffff

next we swap the endianness for the timestamp field we want to convert to date time. For us the most significant field is the ServerReplyTime

$NTPTimeField = SwapEndiannuess $ServerReplayTime

now we "convert" both parts to a 32 bit unsigned integer

[uint32]$fractPart = ($NTPTimeField -band $fractPartMask)
[uint32]$intPart = ($NTPTimeField -band $IntPartMask) -shr 32

now we convert the values to milliseconds and to data type double

[double]$milliseconds = ($intPart * 1000) + ((([double]$fractPart) * 1000) / 0x100000000);

After this we crate a datetime object with the value of 1st January 1900 and add the milliseconds.

$NetworkDateTimeUtc=([DateTime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).AddMilliseconds($milliseconds);

Now the main part is finished and you can set your system clock based on teh recived time information

Set-Date -Date $($NetworkDateTime.ToLocalTime())

Informations for calculating / estimate the delay and the offset of the client local clock

A NTP client estimates the delay between client and server and to determine the ofset of the client clock using the following time stamps:

Timestamp Name ID When Generated
Originate Timestamp T1 time request sent by client
Receive Timestamp T2 time request received by server
Transmit Timestamp T3 time reply sent by server
Destination Timestamp T4 time reply recived by client

The calculation of the estimation is done with this formulas:

Offset = [(t2 + t3) - (t4 + t1)] / 2
Delay = (t4 - t1) - (t3 - t2)

this all is very basic, so we now bring this to a script with functions

Full NTP client script

I'm shure there can be done a lot of improvements in this script an some error handling wouldn't hurt but for now it should do it.

param(
[string[]]$NTPServer
)
BEGIN {
function SwapEndianness {
param(
[ValidateScript({ if ($_ -is [uint64] -or $_ -is [uint32]) { $true } else { write-warning "Invalid datatype" } })]
[Alias("x")]
$SwapValue
)
if ($SwapValue -is [uint64]) {
[uint64]$swapmask = 0xff
[uint64]$retValue = 0
$NumberOfBytes = 8
}
elseif ($SwapValue -is [uint32]) {
[uint32]$swapmask = 0xff
[uint32]$retValue = 0
$NumberOfBytes = 4
}
else {
throw "Invalid data type"
}
$NumberOfBitsPerByte = 8

# Average execution time after 10,000 iterations: 1480.1276 ticks or 0.14801276 milliseconds
<# calculation of the shift values is as follows: 64 bit unsigned integer is divided into 8 bytes each byte is 8 bits
    long the first byte is shifted 56 bits to the left to bring it to the position of the last byte the second byte is
    shifted 40 bits to the left to bring it to the position of the second to last byte the third byte is shifted 24 bits
    to the left to bring it to the position of the third to last byte the fourth byte is shifted 8 bits to the left to
    bring it to the position of the fourth to last byte the fifth byte is shifted 8 bits to the right to bring it to the
    position of the fifth to last byte the sixth byte is shifted 24 bits to the right to bring it to the position of the
    sixth to last byte the seventh byte is shifted 40 bits to the right to bring it to the position of the seventh to
    last byte the eighth byte is shifted 56 bits to the right to bring it to the position of the first byte the shift
    values are calculated as follows: b1 is the byte number starting from 8 b2 is the byte number starting from 1 b1 is
    multipliedby 8 and decremented by b2*8 to get the shift value for the first and the last byte to get for the other
    bytes b1 is decremented by 1 after each iteration and b2 is incremented by 1 after each iteration to get the shift
    value for the swap mask b2 is decremented by 1 first and than multiplied by 8 or in short: y=1 x=n-th byte number.
    In the case of 64 bit integer=8 [shift bits for value] x * 8 - y * 8 [shift bits for mask] (y-1)*8 #>

    $NumberOfShifts = $NumberOfBytes / 2
    $b1 = $NumberOfBytes
    for ($b2 = 1; $b2 -le $NumberOfShifts; $b2++) {

    $shiftForValue = $($b1 * $NumberOfBitsPerByte - $b2 * $NumberOfBitsPerByte)
    $shiftForMask = ($b2 - 1) * $NumberOfBitsPerByte
    # for the bytes that must be shifted to the left you must first shift the mask and do a bitwise and with the value
    than shift the result to the left
    $retValue = $retValue -bor (($swapValue -band ($swapmask -shl $shiftForMask)) -shl $shiftForValue)
    #for the bytes that must be shifted to the right you must first shift the value to the right and then shift the mask
    and do a bitwise and results
    $retValue = $retValue -bor (($swapValue -shr $shiftForValue) -band ($swapmask -shl $shiftForMask))
    #to get all together do a binary or with the result
    $b1--

    }
    return $retValue
    }

    function Convert-DateTimeToNTPTimestamp {
    param(
    [DateTime] $dateTime
    )
    $Epoch = [DateTime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
    $TicksPerSecond = [timespan]::TicksPerSecond
    [double]$ticks = [UInt64]($dateTime.Ticks - $Epoch.Ticks)
    [double]$Seconds = ($ticks / $TicksPerSecond)
    [uint64]$Timestamp = $Seconds * 0x100000000

    # This is the initial used conversion method but I have not found any advantages to the now used
    # [UInt64]$seconds = $ticks / $TicksPerSecond;
    # [UInt64]$fractions = ((([double]$ticks) % $TicksPerSecond) * 0x100000000) / $TicksPerSecond
    # [uint64] $Timestamp = SwapEndianness ($fractions -bor ($seconds -shl 32))
    [uint64] $Timestamp = SwapEndianness $Timestamp
    return $Timestamp
    }


    function Convert-NTPTimeStampToDateTime {
    param(
    [uint64]$NTPTimeField,
    [switch]$NoSwapEndianess
    )
    $Epoch = [DateTime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
    $TicksPerSecond = [timespan]::TicksPerSecond
    if ($NoSwapEndianess) {
    [double]$NTPTimeFieldDouble = $NTPTimeField
    }
    else {
    $NTPTimeField = SwapEndianness $NTPTimeField
    [double]$NTPTimeFieldDouble = $NTPTimeField
    }

    [double]$milliseconds = (($NTPTimeFieldDouble * 1000) / 0x100000000)
    # This is the initial used conversion method but it is less accurate than the above
    # [uint64]$IntPartMask = ([uint64]( -bnot [uint32]0 )) -shl 32 #Workaround for using constant 0xffffffff00000000
    cause suffix u or ul only exists since Powershell 6.2
    # [uint64]$FractPartMask = ([uint64]( -bnot [uint32]0 ))# sufix l (lower cas L) exists in PS 5 but for consistency I
    will use the same workaround for 0x00000000ffffffff
    #There are also other ways to generate the two masks
    # [uint32]$fractPart = ($NTPTimeField -band $fractPartMask)
    # [uint32]$intPart = ($NTPTimeField -band $IntPartMask) -shr 32
    ##[double]$milliseconds2 = ($intPart * 1000) + ((([double]$fractPart) * 1000) / 0x100000000);

    #**UTC** time
    #write-host $milliseconds
    ## return ([DateTime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)).AddMilliseconds($milliseconds);
    return $Epoch.AddMilliseconds($milliseconds)
    }


    function Get-NetworkTime {
    param(
    [string[]] $ntpServer = "pool.ntp.org"
    )
    # $ntpServer = "time.windows.com"

    <# $ntpServer="fits-svr-01.f-it-s.net" $ntpServer="ptbtime1.ptb.de" $ntpServer="time.uni-paderborn.de" #>
        # NTP message size - 16 bytes of the digest (RFC 2030)
        <# NTP Paket format Leap Indicator (LI) 2 bits A code warning of an impending leap second to be inserted or
            deleted in the NTP timescale. The bit values are defined as follows: 00: no warning / 01: last minute has 61
            seconds / 10: last minute has 59 seconds / 11: alarm condition (clock not synchronized) VN (Version Number)
            3 bits NTP version number. The current version is 3. Mode 3 bits NTP mode. The values are defined as
            follows: 0: reserved / 1: symmetric active / 2: symmetric passive / 3: client mode / 4: server mode / 5:
            broadcast mode / 6: reserved for NTP control messages / 7: reserved for private use Stratum 8 bits Stratum
            level of the local clock. It defines the precision of the clock. The value of this field ranges from 1 to
            15. A stratum 1 clock has the highest precision. Poll 8 bits Maximum interval between successive messages.
            Precision 8 bits Precision of the local clock. Root Delay 32 bits Total round-trip delay to the primary
            reference source. Root Dispersion 32 bits Maximum error relative to the primary reference source. Reference
            Identifier 32 bits ID of a reference clock. Reference Timestamp 64 bits Local time at which the local clock
            was last set or corrected. Value 0 indicates that the local clock is never synchronized. Originate Timestamp
            64 bits Local time at which an NTP request packet departed the client for the server. Receive Timestamp 64
            bits Local time at which an NTP request packet arrived at the server. Transmit Timestamp 64 bits Local time
            at which an NTP response packet departed the server for the client. Authenticator 96 bits (Optional)
            Authenticator information. #>

            $TicksPerSecond = [timespan]::TicksPerSecond
            $ntpData = New-Object byte[] 48
            [byte[]]$IdxReferenceIdentifier = 12, 13, 14, 15
            [byte] $IdxRootDelay = 4;
            [byte] $IdxRootDispersion = 8;
            [byte] $IdxReferenceTimestamp = 16;
            [byte] $IdxOriginatingTimestamp = 24;
            [byte] $IdxReceiveTimestamp = 32;
            [byte] $IdxServerReplyTime = 40;
            #Setting the Leap Indicator, Version Number and Mode values
            $ntpData[0] = 0x1B; #LI = 0 (no warning), VN = 3 (IPv4 only), Mode = 3 (Client Mode)
            $ntpData[0] = 0 -shl 6
            $ntpData[0] = $ntpData[0] -bor (3 -shl 3) #Version
            $ntpData[0] = $ntpData[0] -bor 3 # NTP Mode

            $addresses = [System.Net.Dns]::GetHostEntry($ntpServer).AddressList;

            #The UDP port number assigned to NTP is 123
            $ipEndPoint = new-object IPEndpoint($addresses[0], 123)
            #NTP uses UDP

            $socket = New-Object System.Net.Sockets.Socket([System.Net.Sockets.AddressFamily]::InterNetwork,
            [System.Net.Sockets.SocketType]::Dgram, [System.Net.Sockets.ProtocolType]::Udp)

            $socket.Connect($ipEndPoint);

            #Stops code hang if NTP is blocked
            $socket.ReceiveTimeout = 6000;
            $socket.SendTimeout = 3000;
            $localSendTime = [DateTime]::UtcNow
            #[uint64]$LocalSendTimestamp = Convert-DateTimeToNTPTimestamp -dateTime $localSendTime
            #Creating timestamp from the current local time and store it in the bytes with index 40 - 47. The NTP Server
            copies this timestamp to the Originating Timestamp field of the response.
            [System.BitConverter]::GetBytes((Convert-DateTimeToNTPTimestamp -dateTime
            ([DateTime]::UtcNow))).CopyTo($ntpData, $IdxServerReplyTime)
            [int32]$BytesSentReceived = $socket.Send($ntpData);
            $BytesSentReceived = $socket.Receive($ntpData);
            $LocalReciveTime = [DateTime]::UtcNow
            $LocalReciveTimestamp = Convert-DateTimeToNTPTimestamp -dateTime $LocalReciveTime
            #$LocalReciveTimestampLE = SwapEndianness $LocalReciveTimestamp
            $LocalReciveTimeFromTS = Convert-NTPTimeStampToDateTime -NTPTimeField $LocalReciveTimestamp
            $socket.Close();
            $socket.Dispose();
            $enc = [System.Text.Encoding]::ASCII
            #Offset to get to the "Transmit Timestamp" field (time at which the reply
            #departed the server for the client, in 64-bit timestamp format."
            [uint32]$Header = [System.BitConverter]::ToUInt32($ntpData, 0)
            [byte]$leap = $Header -shr 6 -band 3
            [byte]$ServerVersion = $Header -shr 3 -band 7
            [byte]$NTPMode = $Header -band 7
            [byte]$stratum = $Header -shr 8 -band 255
            [byte]$poll = $header -shr 16 -band 255
            [byte]$Precision = $header -shr 24 -band 255
            [uint32]$RootDelay = [System.BitConverter]::ToUInt32($ntpData, $IdxRootDelay)
            [uint32]$RootDispersion = [System.BitConverter]::ToUInt32($ntpData, $IdxRootDispersion)
            if ($stratum -eq 1) {
            [string]$ReferenceIdentifier = $enc.GetString($ntpData[$IdxReferenceIdentifier])

            }
            else {
            [ipaddress]$ReferenceIdentifier = [ipaddress]::new($ntpData[$IdxReferenceIdentifier])
            }

            [uint64]$ReferenceTimestamp = [System.BitConverter]::ToUInt64($ntpData, $idxReferenceTimestamp)
            [uint64]$OriginateTimestamp = [System.BitConverter]::ToUInt64($ntpData, $IdxOriginatingTimestamp)

            [uint64]$ReceiveTimestamp = [System.BitConverter]::ToUInt64($ntpData, $IdxReceiveTimestamp)
            [uint64]$ServerReplayTime = [System.BitConverter]::ToUInt64($ntpData, $IdxserverReplyTime)
            [uint32]$TempRootDelay = SwapEndianness -x $RootDelay
            [uint32]$RootDelay = [System.BitConverter]::ToUInt32($ntpData, $IdxRootDelay)
            [uint32]$TempRootDelay = SwapEndianness -SwapValue $RootDelay
            [int32]$TempRootDelaySigned =
            [System.BitConverter]::ToInt32(([System.BitConverter]::GetBytes($TempRootDelay)), 0)
            [single]$fRootDelay = ([Single]$TempRootDelaySigned) / (1 -shl 16)
            [uint32]$RootDispersion = SwapEndianness -SwapValue ([System.BitConverter]::ToUInt32($ntpData,
            $IdxRootDispersion))
            [int32]$RootDispersionSigned = $RootDispersion -band (-bnot ([uint32]1 -shl 31 ))
            if ($RootDispersion -band ([uint32]1 -shl 31 )) {
            $RootDispersionSigned = $RootDispersionSigned -bor ([int32]1 -shl 31)
            }
            [single]$fRootDispersion = ([single]$RootDispersionSigned) / (1 -shl 16)

            $ReceiveTime = Convert-NTPTimeStampToDateTime -NTPTimeField $ReceiveTimestamp
            $ReferenceTime = Convert-NTPTimeStampToDateTime -NTPTimeField $ReferenceTimestamp
            $OriginateTime = Convert-NTPTimeStampToDateTime -NTPTimeField $OriginateTimestamp
            $networkDateTime = Convert-NTPTimeStampToDateTime -NTPTimeField $ServerReplayTime

            $ReturnObj = New-Object psobject -Property $([ordered]@{
            LeapIndicator = $leap
            Version = $ServerVersion
            Mode = $NTPMode
            Stratum = $stratum
            Poll = $poll
            Precision = $Precision
            RootDelay = $fRootDelay
            RootDispersion = $fRootDispersion
            ReferenceIdentifier = $ReferenceIdentifier
            ReferenceTimestamp = $ReferenceTime
            OriginateTimestamp = $OriginateTime
            ReceiveTimestamp = $ReceiveTime
            TransmitTimestamp = $networkDateTime
            UsedNTPServer = $ntpServer
            UsedNTPServerIP = $addresses[0]
            LocalReciveTime = $LocalReciveTime
            RoundtripDelay = ($LocalReciveTimeFromTS - $OriginateTime).TotalSeconds - ($networkDateTime -
            $ReceiveTime).TotalSeconds
            Offset = ((( $ReceiveTime.Ticks / $TicksPerSecond) + ($networkDateTime.ticks / $TicksPerSecond)) -
            (($LocalReciveTimeFromTS.Ticks / $TicksPerSecond) + ($OriginateTime.Ticks / $TicksPerSecond))) / 2
            })
            #$networkDateTime - $ReceiveTime
            return $ReturnObj
            }
            }
            PROCESS {
            foreach ($NTPSrv in $NTPServer) {
            Get-NetworkTime $NTPSrv
            }
            }


            ```