Skip to content

Instantly share code, notes, and snippets.

@nicolas17
Forked from MCJack123/ipsw_keys.py
Last active August 17, 2023 03:02
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save nicolas17/bb6d9ac092bd2a43ba732458243735d5 to your computer and use it in GitHub Desktop.
Save nicolas17/bb6d9ac092bd2a43ba732458243735d5 to your computer and use it in GitHub Desktop.
Extract iOS firmware keys using on-device AES engine - MOVED TO https://github.com/nicolas17/ipsw_keys
#!/usr/bin/env python
from sys import argv, stdout
from os import system, remove, path
from urlparse import urlparse
import re
import dfu
import ssl
import math
import json
import getopt
import image3
import dfuexec
import httplib
import usbexec
import zipfile
import plistlib
####################### BEGIN ASN1 DECODER ############################
# Author: Jens Getreu, 8.11.2014
##### NAVIGATE
# The following 4 functions are all you need to parse an ASN1 structure
# gets the first ASN1 structure in der
def asn1_node_root(der):
return asn1_read_length(der,0)
# gets the next ASN1 structure following (ixs,ixf,ixl)
def asn1_node_next(der, (ixs,ixf,ixl)):
return asn1_read_length(der,ixl+1)
# opens the container (ixs,ixf,ixl) and returns the first ASN1 inside
def asn1_node_first_child(der, (ixs,ixf,ixl)):
if ord(der[ixs]) & 0x20 != 0x20:
raise ValueError('Error: can only open constructed types. '
+'Found type: 0x'+der[ixs].encode("hex"))
return asn1_read_length(der,ixf)
# is true if one ASN1 chunk is inside another chunk.
def asn1_node_is_child_of((ixs,ixf,ixl), (jxs,jxf,jxl)):
return ( (ixf <= jxs ) and (jxl <= ixl) ) or \
( (jxf <= ixs ) and (ixl <= jxl) )
##### END NAVIGATE
##### ACCESS PRIMITIVES
# get content and verify type byte
def asn1_get_value_of_type(der,(ixs,ixf,ixl),asn1_type):
asn1_type_table = {
'BOOLEAN': 0x01, 'INTEGER': 0x02,
'BIT STRING': 0x03, 'OCTET STRING': 0x04,
'NULL': 0x05, 'OBJECT IDENTIFIER': 0x06,
'SEQUENCE': 0x70, 'SET': 0x71,
'PrintableString': 0x13, 'IA5String': 0x16,
'UTCTime': 0x17, 'ENUMERATED': 0x0A,
'UTF8String': 0x0C,
}
if asn1_type_table[asn1_type] != ord(der[ixs]):
raise ValueError('Error: Expected type was: '+
hex(asn1_type_table[asn1_type])+
' Found: 0x'+der[ixs].encode('hex'))
return der[ixf:ixl+1]
# get value
def asn1_get_value(der,(ixs,ixf,ixl)):
return der[ixf:ixl+1]
# get type+length+value
def asn1_get_all(der,(ixs,ixf,ixl)):
return der[ixs:ixl+1]
##### END ACCESS PRIMITIVES
##### HELPER FUNCTIONS
# converter
def bitstr_to_bytestr(bitstr):
if bitstr[0] != '\x00':
raise ValueError('Error: only 00 padded bitstr can be converted to bytestr!')
return bitstr[1:]
# converter
def bytestr_to_int(s):
# converts bytestring to integer
i = 0
for char in s:
i <<= 8
i |= ord(char)
return i
# ix points to the first byte of the asn1 structure
# Returns first byte pointer, first content byte pointer and last.
def asn1_read_length(der,ix):
first= ord(der[ix+1])
if (ord(der[ix+1]) & 0x80) == 0:
length = first
ix_first_content_byte = ix+2
ix_last_content_byte = ix_first_content_byte + length -1
else:
lengthbytes = first & 0x7F
length = bytestr_to_int(der[ix+2:ix+2+lengthbytes])
ix_first_content_byte = ix+2+lengthbytes
ix_last_content_byte = ix_first_content_byte + length -1
return (ix,ix_first_content_byte,ix_last_content_byte)
##### END HELPER FUNCTIONS
####################### END ASN1 DECODER ############################
serial_number = None
cpid = None
bdid = None
deviceIdentifier = None
versionMap = {}
def getInfo():
global serial_number, cpid, bdid
dev = dfu.acquire_device()
serial_number = dev.serial_number
print("Found:" + serial_number)
if not "PWND:[" in serial_number:
print "Please enable pwned DFU Mode first."
exit(4)
cpid_m = re.search("CPID:([0-9A-F]{4})", serial_number)
if cpid_m == None:
print("Could not find CPID in serial")
exit(4)
cpid = cpid_m.group(1)
if bdid == None:
bdid_m = re.search("BDID:([0-9A-F][0-9A-F])", serial_number)
if bdid_m == None:
print("Could not find BDID in serial")
exit(4)
bdid = bdid_m.group(1)
dfu.release_device(dev)
def getRawKeybag(der):
if der[:4] == "3gmI":
kbag = image3.Image3(der).getKeybag()
return kbag # may be None
else:
dec = asn1_node_next(der, asn1_node_next(der, asn1_node_next(der, asn1_node_first_child(der, asn1_node_root(der)))))
if dec[2] >= len(der) - 4:
return None
kbag_node = asn1_node_next(der, dec)
# make sure it's an OCTET STRING
if ord(der[kbag_node[0]]) != 0x4:
return None
kbag = asn1_get_value(der, kbag_node)
dec = asn1_node_next(kbag, asn1_node_first_child(kbag, asn1_node_first_child(kbag, asn1_node_root(kbag))))
ivenc = asn1_get_value(kbag, dec)
keyenc = asn1_get_value(kbag, asn1_node_next(kbag, dec))
return (ivenc + keyenc)
def getKeysFromDevice(keybag):
if 'PWND:[checkm8]' in serial_number:
pwned = usbexec.PwnedUSBDevice()
keys = pwned.aes(keybag, usbexec.AES_DECRYPT, usbexec.AES_GID_KEY).encode('hex')
else:
device = dfuexec.PwnedDFUDevice()
keys = device.aes(keybag, dfuexec.AES_DECRYPT, dfuexec.AES_GID_KEY).encode("hex")
return (keys[:32], keys[32:])
def getIdentity(manifest, restoreBehavior, identityType):
try:
identity = next(item for item in manifest["BuildIdentities"] if item["ApChipID"] == "0x" + cpid and item["ApBoardID"] == "0x" + bdid and item["Info"]["RestoreBehavior"] == restoreBehavior)
except StopIteration:
print("Error: Could not find " + identityType + " identity for CPID " + cpid + " and BDID " + bdid + " in manifest")
exit(5)
if identity == None:
print("Error: Could not find " + identityType + " identity for CPID " + cpid + " and BDID " + bdid + " in manifest")
exit(5)
return identity
def convertKeys(zipfile, identity, identityType, kbagOnly=False):
output = {}
for k,v in identity["Manifest"].items():
if not "Path" in v["Info"].keys(): continue
if k == "OS":
output["RootFS"] = {"Path": v["Info"]["Path"], "Encrypted": False}
continue
if not (any(v["Info"]["Path"].endswith(suffix) for suffix in ("im4p", "img3", "trustcache", "dmg")) or k == "RestoreRamDisk" or k == "KernelCache"): continue
if identityType == 'update':
if k == "RestoreRamDisk": k = "UpdateRamDisk"
if k == "RestoreTrustCache": k = "UpdateTrustCache"
kbag = getRawKeybag(zipfile.read(v["Info"]["Path"]))
if kbag == None:
output[k] = {"Path": v["Info"]["Path"], "Encrypted": False}
else:
if 'SEP' in k or kbagOnly:
output[k] = {"Path": v["Info"]["Path"], "Encrypted": True, "KBAG": str(kbag).encode('hex')}
else:
iv,key = getKeysFromDevice(kbag)
output[k] = {"Path": v["Info"]["Path"], "Encrypted": True, "IV": iv, "Key": key}
return output
def extractKeys(infile, outfile, outtype=0, delete=False, infodict=None):
print("Reading manifest...")
zip = zipfile.ZipFile(infile)
manifest = plistlib.readPlistFromString(zip.read("BuildManifest.plist"))
print("Reading keys...")
output = {}
altOutput = None
maxlen = 11
for (restoreBehavior, identityType) in (('Erase','restore'), ('Update','update')):
identity = getIdentity(manifest, restoreBehavior, identityType)
output.update(convertKeys(zip, identity, identityType))
boardConfig = identity["Info"]["DeviceClass"]
boardConfig2 = None
otherDevices = [item for item in manifest["BuildIdentities"] if re.sub(r'^([a-z][0-9]+).*',r'\1',item["Info"]["DeviceClass"]) == re.sub(r'^([a-z][0-9]+).*',r'\1',boardConfig) and item["ApBoardID"] == "0x"+bdid and item["ApChipID"] != "0x"+cpid and item["Info"]["RestoreBehavior"] == "Erase"]
print("Found {} other devices:".format(len(otherDevices)))
for dev in otherDevices:
print(" bdid {} cpid {} boardconfig {}".format(dev["ApBoardID"], dev["ApChipID"], dev["Info"]["DeviceClass"]))
if len(otherDevices) == 1:
altOutput = convertKeys(zip, otherDevices[0], 'restore', kbagOnly=True)
boardConfig2 = otherDevices[0]["Info"]["DeviceClass"]
shouldSwap = any(boardConfig.endswith(suffix) for suffix in ('uap','map','tap'))
if shouldSwap:
print("Main model is {}, secondary model is {}, let's swap them".format(boardConfig, boardConfig2))
(boardConfig, boardConfig2) = (boardConfig2, boardConfig)
for k,v in altOutput.items():
if k in ('RestoreSEP','RestoreDeviceTree'): continue
if v['Path'] != output[k]['Path']:
if shouldSwap:
output[k+'2'] = output[k]
output[k] = v
else:
output[k+'2'] = v
elif len(otherDevices) > 1:
print("error: expected to get only one 'other device'")
exit(6)
ProductType = None
if deviceIdentifier is not None:
ProductType = deviceIdentifier
elif "Restore.plist" in zip.namelist():
restore = plistlib.readPlistFromString(zip.read("Restore.plist"))
if "ProductType" in restore.keys():
ProductType = restore["ProductType"]
elif "SupportedProductTypes" in restore.keys() and len(restore["SupportedProductTypes"]) == 1:
ProductType = restore["SupportedProductTypes"][0]
# Add Maggie if the file exists in the zip
try:
zip.getinfo("Firmware/Maggie/AppleMaggieFirmwareImage.im4p")
output["AppleMaggie"] = {"Path": "Firmware/Maggie/AppleMaggieFirmwareImage.im4p", "Encrypted": False}
except KeyError:
pass
maxlen = max(maxlen, max(len(k) for k in output.keys())) + 4
zip.close()
file = open(outfile, "w")
if outtype == 0: json.dump(output, file)
elif outtype == 1: file.write(plistlib.writePlistToString(output))
else:
# use manifest["ProductVersion"] unless the build ID is present in the versionMap override
productVersion = versionMap.get(manifest["ProductBuildVersion"], manifest["ProductVersion"])
file.write("{{keys\n")
file.write(" | {} = {}\n".format("Version".ljust(maxlen), productVersion))
file.write(" | {} = {}\n".format("Build".ljust(maxlen), manifest["ProductBuildVersion"]))
file.write(" | {} = {}\n".format("Device".ljust(maxlen), infodict["identifier"] if infodict != None else (ProductType if ProductType != None else "?")))
file.write(" | {} = {}\n".format("Codename".ljust(maxlen), identity["Info"]["BuildTrain"]))
if 'BasebandFirmware' in identity["Manifest"]:
file.write(" | {} = {}\n".format("Baseband".ljust(maxlen), re.sub(r'^Firmware/.*-([0-9.]+)\.\w+\.bbfw$', r'\1', identity["Manifest"]["BasebandFirmware"]["Info"]["Path"])))
file.write(" | {} = {}\n".format("DownloadURL".ljust(maxlen), infodict["url"] if infodict != None else "?"))
file.write("\n")
output["GlyphPlugin"] = output["BatteryPlugin"]
if 'BatteryPlugin2' in output:
output["GlyphPlugin2"] = output["BatteryPlugin2"]
def niceBoardConfig(bc):
return (bc.upper()
.replace('UAP','uAP')
.replace('MAP','mAP')
.replace('SAP','sAP')
.replace('TAP','tAP')
.replace('BAP','bAP')
)
if boardConfig2:
file.write(" | {} = {}\n".format('Model'.ljust(maxlen), niceBoardConfig(boardConfig)))
file.write(" | {} = {}\n".format('Model2'.ljust(maxlen), niceBoardConfig(boardConfig2)))
file.write("\n")
del output["BatteryPlugin"]
for k in ["RootFS", "RootFS2", "UpdateRamDisk", "UpdateRamDisk2", "RestoreRamDisk", "RestoreRamDisk2"]:
if k not in output.keys(): continue
v = output[k]
if k == "RestoreRamDisk": wk = "RestoreRamdisk"
elif k == "UpdateRamDisk": wk = "UpdateRamdisk"
elif k == "RestoreRamDisk2": wk = "RestoreRamdisk2"
elif k == "UpdateRamDisk2": wk = "UpdateRamdisk2"
else: wk = k
file.write(" | " + wk.ljust(maxlen) + " = " + path.basename(v["Path"]).replace(".dmg", "") + "\n")
if v["Encrypted"]:
if "KBAG" in v.keys():
file.write(" | " + (wk + "IV").ljust(maxlen) + " = Unknown\n | " + (wk + "Key").ljust(maxlen) + " = Unknown\n")
file.write(" | " + (wk + "KBAG").ljust(maxlen) + " = " + v["KBAG"] + "\n\n")
else:
file.write(" | " + (wk + "IV").ljust(maxlen) + " = " + v["IV"] + "\n")
file.write(" | " + (wk + "Key").ljust(maxlen) + " = " + v["Key"] + "\n\n")
elif k == "RootFS" and manifest["ProductVersion"][0] != "1":
file.write(" | " + (wk + "IV").ljust(maxlen) + " = ?\n")
file.write(" | " + (wk + "Key").ljust(maxlen) + " = ?\n\n")
else:
file.write(" | " + (wk + ("Key" if wk == "RootFS" else "IV")).ljust(maxlen) + " = Not Encrypted\n\n")
del output[k]
for k,v in sorted(output.items(), key=lambda k: (k[0].lower(), k[1])):
if k.replace('2','') in ("RestoreSEP", "RestoreDeviceTree", "RestoreTrustCache", "UpdateTrustCache", "StaticTrustCache", "RestoreLogo"): continue
if k == "KernelCache": wk = "Kernelcache"
elif k == "KernelCache2": wk = "Kernelcache2"
elif k == "SEP": wk = "SEPFirmware"
elif k == "SEP2": wk = "SEPFirmware2"
elif k == "AOP": wk = "AOPFirmware"
elif k == "AOP2": wk = "AOPFirmware2"
elif k == "Liquid": wk = "LiquidDetect"
else: wk = k
file.write(" | " + wk.ljust(maxlen) + " = " + path.basename(v["Path"]).replace(".dmg", "") + "\n")
if v["Encrypted"]:
if "KBAG" in v.keys():
file.write(" | " + (wk + "IV").ljust(maxlen) + " = Unknown\n | " + (wk + "Key").ljust(maxlen) + " = Unknown\n")
file.write(" | " + (wk + "KBAG").ljust(maxlen) + " = " + v["KBAG"] + "\n\n")
else:
file.write(" | " + (wk + "IV").ljust(maxlen) + " = " + v["IV"] + "\n")
file.write(" | " + (wk + "Key").ljust(maxlen) + " = " + v["Key"] + "\n\n")
else:
file.write(" | " + (wk + "IV").ljust(maxlen) + " = Not Encrypted\n\n")
file.write("}}")
file.close()
if delete:
print("Cleaning up...")
remove(infile)
print("Keys saved to " + outfile)
def parseVersionMap(path):
versionMap = {}
with open(path, "r") as f:
for line in f:
build, _, version = line.strip().partition(" ")
versionMap[build] = version
return versionMap
def usage():
print("Usage: " + argv[0] + " <-i <input>|-d <identifier>> [-jpw] [-v <version>] [-b <bdid>] [options] [-a] [-o <output>] [-m <model>]")
print("Extracts iOS encryption keys from an IPSW using a physical device's AES engine.")
print("")
print("Required arguments:")
print(" -d, --device <identifier> Device identifier to download IPSW for")
print(" -i, --input <input> Local IPSW file to read from")
print(" -o, --output <output> Location to store output keys")
print("")
print("Optional arguments:")
print(" -a, --auto-name Automatically name output based on version and device, and save in folder at <output> if specified")
print(" -b, --bdid <BDID>|* Use a custom board ID instead of the current device's BDID")
print(" -h, --help Show this help prompt")
print(" -j, --json Store output as JSON file")
print(" -p, --plist Store output as property list file")
print(" -v, --version <version> Version of iOS to download (without this, downloads all versions and implies -a)")
print(" -w, --wiki Format output for iPhone Wiki upload")
print(" -m, --model <model> Device model (like iPhone8,4) to include in wiki format")
print(" --version-map <file> Path to a file listing build IDs and version numbers (like '17A5547d 13.0 beta 5')")
def getext(t):
if t == 0: return "json"
elif t == 1: return "plist"
else: return "wiki"
if __name__ == "__main__":
if len(argv) == 1:
usage()
exit(0)
optlist, args = getopt.getopt(argv[1:], "hi:o:d:v:ajpwb:m:", ["device=", "input=", "output=", "auto-name", "json", "plist", "version=", "wiki", "help", "bdid=", "model=", "version-map="])
inputName = None
inputDevice = None
inputVersion = None
outputName = None
outputType = None
autoName = False
versionMapPath = None
for o, a in optlist:
if o == "-j" or o == "--json":
if outputType == None: outputType = 0
else:
print("Error: Only one of -j, -p, -w can be specified.")
exit(1)
elif o == "-p" or o == "--plist":
if outputType == None: outputType = 1
else:
print("Error: Only one of -j, -p, -w can be specified.")
exit(1)
elif o == "-w" or o == "--wiki":
if outputType == None: outputType = 2
else:
print("Error: Only one of -j, -p, -w can be specified.")
exit(1)
elif o == "-d" or o == "--device":
inputDevice = a
elif o == "-i" or o == "--input":
inputName = a
elif o == "-o" or o == "--output":
outputName = a
elif o == "-v" or o == "--version":
inputVersion = a
elif o == "-a" or o == "--auto-name":
autoName = True
elif o == "-h" or o == "--help":
usage()
exit(0)
elif o == "-b" or o == "--bdid":
bdid = a
elif o == "-m" or o == "--model":
deviceIdentifier = a
elif o == '--version-map':
versionMapPath = a
if outputType == None: outputType = 0
if inputName == None and inputDevice == None:
print("Error: No input file or device specified.")
exit(1)
if outputName == None and autoName == False:
print("Error: No output file specified.")
exit(1)
if inputName != None and inputDevice != None:
print("Error: Only one of -i, -d can be specified.")
exit(1)
if inputName != None and inputVersion != None:
print("Error: -v cannot be used in conjunction with -i.")
exit(1)
infoDict = None
getInfo()
if inputName == None:
def getFile(url):
uri = urlparse(url)
con = httplib.HTTPSConnection(uri.netloc, context=ssl.SSLContext(ssl.PROTOCOL_SSLv23))
con.request("GET", uri.path)
res = con.getresponse()
if math.floor(res.status / 100) != 2:
print(res.status)
return None
retval = res.read()
con.close()
return retval
firmwares_json = getFile("https://api.ipsw.me/v4/device/" + inputDevice)
if firmwares_json == None:
print("Error: Unknown device type " + inputDevice)
exit(2)
firmwares = json.loads(firmwares_json)
if firmwares == None:
print("Error: Could not decode firmwares json")
exit(2)
if inputVersion == None:
print("Warning: -v was not specified, downloading all versions available. This will take a long time. Did you mean to specify -v?")
for firm in firmwares["firmwares"]:
print("Downloading iOS " + firm["version"] + " (" + firm["buildid"] + ") for device " + firm["identifier"] + "...")
system("curl -o firmware.ipsw -L --progress-bar " + firm["url"])
extractKeys("firmware.ipsw", outputName + "/" + firm["identifier"] + "_" + firm["version"] + "_" + firm["buildid"] + "_Keys.json", outtype=outputType, delete=True, infodict=firm)
exit(0)
try:
firm = next(item for item in firmwares["firmwares"] if item["version"] == inputVersion)
except StopIteration:
print("Error: Unknown version " + inputVersion + " for device " + inputDevice)
exit(3)
if firm == None:
print("Error: Unknown version " + inputVersion + " for device " + inputDevice)
exit(3)
print("Downloading iOS " + firm["version"] + " (" + firm["buildid"] + ") for device " + firm["identifier"] + "...")
system("curl -o firmware.ipsw -L --progress-bar " + firm["url"])
inputName = "firmware.ipsw"
infoDict = firm
if autoName: outputName = (outputName + "/" if outputName != None else "") + firm["identifier"] + "_" + firm["version"] + "_" + firm["buildid"] + "_Keys." + getext(outputType)
elif autoName:
if re.match("iP\w+?[0-9a-z._,]+?[0-9.]+_[0-9]+[A-Z][0-9]+_Restore.ipsw", path.basename(inputName)):
m = re.match("(iP\w+?[0-9a-z._,]+?[0-9.]+_[0-9]+[A-Z][0-9]+)_Restore.ipsw", path.basename(inputName))
outputName = m.group(1) + "_Keys." + getext(outputType)
else: outputName = outputName + "/" + inputName.replace(".ipsw", "") + "_Keys." + getext(outputType)
if versionMapPath:
versionMap = parseVersionMap(versionMapPath)
extractKeys(inputName, outputName, outtype=outputType, delete=inputDevice != None, infodict=infoDict)

How to use this

  1. Download the script to the same folder as ipwndfu
  2. Boot the device in DFU mode and run checkm8 exploit
  3. Run the script using the arguments listed below

Usage

Usage: ipsw_key.py <-i <input>|-d <identifier>> [-ajpw] [-v <version>] [-b <bdid>] [options] [-a] [-o <output>]
Extracts iOS encryption keys from an IPSW using a physical device's AES engine.

Required arguments:
    -d, --device <identifier>    Device identifier to download IPSW for
    -i, --input <input>          Local IPSW file to read from
    -o, --output <output>        Location to store output keys
    
Optional arguments:
    -a, --auto-name              Automatically name output based on version and device, and save in folder at <output> if specified
    -b, --bdid <bdid>            Use a custom board ID instead of the current device's BDID
    -h, --help                   Show this help prompt
    -j, --json                   Store output as JSON file
    -p, --plist                  Store output as property list file
    -v, --version <version>      Version of iOS to download (without this, downloads all versions and implies -a)
    -w, --wiki                   Format output for iPhone Wiki upload

Examples

  • ./ipsw_keys.py -d iPad4,5 -v 12.4.2 --auto-name - Downloads the keys for iOS 12.4.2 for iPad4,5 to iPad4,5_12.4.2_16G114_Keys.json
  • ./ipsw_keys.py -d iPhone5,1 -o iPhone5Keys -w - Downloads the keys for all versions for iPhone5,1 to iPhone5Keys/iPhone5,1_<version>_<build>_Keys.wiki
  • ./ipsw_keys.py -i iPhone10,3,iPhone10,6_12.4.1_16G102_Restore.ipsw -o iPhoneXKeys_12.4.1.plist --plist - Extracts keys from iPhone10,3,iPhone10,6_12.4.1_16G102_Restore.ipsw to iPhoneXKeys_12.4.1.plist

Notes

  • If you specify -d without -v, all iOS versions will be downloaded, and -a is implied. THIS WILL TAKE A LONG TIME!
  • If you specify -a, the -o option will instead be used for the output folder. In this case, you can skip -o to save in the current directory.
  • This will not get the keys for the RootFS before 10.0 since it was stored in a different non-IMG4 format that I can't parse. These keys are mostly available on the iPhone Wiki anyway, so it shouldn't matter too much; plus, iOS 10 and later don't encrypt the RootFS at all.
  • The output JSON and plist files will be in a format similar to this:
{
    "BatteryLow0": {
        "Path": "Firmware/all_flash/batterylow0@2x~ipad.im4p",
        "Encrypted": false
    },
    [...]
    "iBSS": {
        "Path": "Firmware/dfu/iBSS.ipad4b.RELEASE.im4p",
        "Encrypted": true,
        "IV": "00112233445566778899aabbccddeeff",
        "Key": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment