-
Notifications
You must be signed in to change notification settings - Fork 14
feat(deployment): check AMD CRL revocation in attestation validation #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
8050aa8
feat(deployment): check AMD CRL revocation in attestation validation
baktun14 6bed4db
fix(deployment): fail closed on missing CRL freshness; assert CRL pro…
baktun14 d5ecc28
fix(deployment): reject out-of-range CRL time components
baktun14 e45fe6e
refactor(deployment): tidy AMD CRL revocation code and dedupe CRL fix…
baktun14 d712a95
test(deployment): add AMD KDS client unit tests
baktun14 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
157 changes: 157 additions & 0 deletions
157
apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { X509Certificate } from "node:crypto"; | ||
| import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { afterAll, beforeAll, describe, expect, it } from "vitest"; | ||
|
|
||
| import { AmdCrlParseError, isCertRevoked, isCrlExpired, normalizeSerial, parseAmdCrl, verifyCrlSignature } from "./amd-crl.parser"; | ||
| import { generateCrl } from "./amd-crl.test-fixtures"; | ||
|
|
||
| // A real RSA ARK→ASK chain plus RSA-PSS CRLs (clean and one revoking the ASK), generated once via | ||
| // openssl to mirror AMD KDS — which issues RSA-4096 ARKs and signs its CRLs with RSA-PSS/SHA-384. | ||
| // Hermetic: no committed keys, no network. | ||
| let fixtures: ReturnType<typeof generateCrlFixtures>; | ||
|
|
||
| beforeAll(() => { | ||
| fixtures = generateCrlFixtures(); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| if (fixtures) rmSync(fixtures.dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe(parseAmdCrl.name, () => { | ||
| it("parses nextUpdate and an empty revoked list from a clean CRL", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(crl.revokedSerials.size).toBe(0); | ||
| expect(crl.nextUpdate).toBeInstanceOf(Date); | ||
| expect((crl.nextUpdate as Date).getTime()).toBeGreaterThan(Date.now()); | ||
| }); | ||
|
|
||
| it("extracts the revoked serial number from a CRL with revocations", () => { | ||
| const crl = parseAmdCrl(fixtures.revokedCrlDer); | ||
|
|
||
| expect(crl.revokedSerials.has(normalizeSerial(fixtures.askCert.serialNumber))).toBe(true); | ||
| }); | ||
|
|
||
| it("throws AmdCrlParseError for bytes that are not a CRL", () => { | ||
| expect(() => parseAmdCrl(Buffer.from("clearly not a DER-encoded CRL"))).toThrow(AmdCrlParseError); | ||
| }); | ||
|
|
||
| it("rejects an out-of-range time component instead of silently normalizing it", () => { | ||
| // Set nextUpdate's month to "13"; Date.UTC would roll it to next January without the round-trip guard. | ||
| const corrupted = withNextUpdateMonth(fixtures.cleanCrlDer, "13"); | ||
|
|
||
| expect(() => parseAmdCrl(corrupted)).toThrow(AmdCrlParseError); | ||
| }); | ||
| }); | ||
|
|
||
| describe(verifyCrlSignature.name, () => { | ||
| it("verifies an RSA-PSS CRL signed by the issuing ARK", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(verifyCrlSignature(crl, fixtures.arkCert)).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects a CRL when verified against a certificate that did not sign it", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(verifyCrlSignature(crl, fixtures.askCert)).toBe(false); | ||
| }); | ||
|
|
||
| it("rejects a CRL whose signed content was tampered with", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
| const tampered = { ...crl, tbsDer: Buffer.from(crl.tbsDer) }; | ||
| tampered.tbsDer[20] ^= 0xff; | ||
|
|
||
| expect(verifyCrlSignature(tampered, fixtures.arkCert)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe(isCertRevoked.name, () => { | ||
| it("returns true when the certificate serial is listed as revoked", () => { | ||
| const crl = parseAmdCrl(fixtures.revokedCrlDer); | ||
|
|
||
| expect(isCertRevoked(crl, fixtures.askCert)).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false when the certificate serial is not listed", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(isCertRevoked(crl, fixtures.askCert)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe(normalizeSerial.name, () => { | ||
| it("strips a leading sign byte and uppercases", () => { | ||
| expect(normalizeSerial("00a1b2")).toBe("A1B2"); | ||
| }); | ||
|
|
||
| it("treats serials that differ only by leading zeros as equal", () => { | ||
| expect(normalizeSerial("0010001")).toBe(normalizeSerial("10001")); | ||
| }); | ||
|
|
||
| it("preserves an all-zero serial", () => { | ||
| expect(normalizeSerial("00")).toBe("0"); | ||
| }); | ||
| }); | ||
|
|
||
| describe(isCrlExpired.name, () => { | ||
| it("returns true once now is at or past nextUpdate", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(isCrlExpired(crl, new Date((crl.nextUpdate as Date).getTime() + 1000))).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false while the CRL is still current", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(isCrlExpired(crl, new Date((crl.nextUpdate as Date).getTime() - 1000))).toBe(false); | ||
| }); | ||
|
|
||
| it("treats a CRL with no freshness bound as expired (fails closed)", () => { | ||
| const crl = parseAmdCrl(fixtures.cleanCrlDer); | ||
|
|
||
| expect(isCrlExpired({ ...crl, nextUpdate: null }, new Date())).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| function generateCrlFixtures() { | ||
| const dir = mkdtempSync(join(tmpdir(), "amd-crl-")); | ||
| const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); | ||
| const rsaKey = (name: string) => ossl(["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", name]); | ||
|
|
||
| rsaKey("ark.key"); | ||
| ossl(["req", "-new", "-x509", "-key", "ark.key", "-out", "ark.pem", "-days", "2", "-subj", "/CN=ARK-Milan", "-sha384"]); | ||
| rsaKey("ask.key"); | ||
| ossl(["req", "-new", "-key", "ask.key", "-out", "ask.csr", "-subj", "/CN=SEV-Milan", "-sha384"]); | ||
| ossl(["x509", "-req", "-in", "ask.csr", "-CA", "ark.pem", "-CAkey", "ark.key", "-CAcreateserial", "-out", "ask.pem", "-days", "2", "-sha384"]); | ||
|
|
||
| const arkCert = new X509Certificate(readFileSync(join(dir, "ark.pem"))); | ||
| const askCert = new X509Certificate(readFileSync(join(dir, "ask.pem"))); | ||
|
|
||
| const cleanCrlDer = generateCrl(dir, []); | ||
| const revokedCrlDer = generateCrl(dir, [askCert.serialNumber]); | ||
|
|
||
| return { dir, arkCert, askCert, cleanCrlDer, revokedCrlDer }; | ||
| } | ||
|
|
||
| /** Returns a copy of the CRL DER with nextUpdate's 2-digit month overwritten, to forge an invalid time. */ | ||
| function withNextUpdateMonth(der: Buffer, month: string): Buffer { | ||
| const copy = Buffer.from(der); | ||
| let seen = 0; | ||
| for (let i = 0; i + 15 <= copy.length; i++) { | ||
| // UTCTime = tag 0x17, length 0x0d, then 13 ASCII bytes ending in 'Z' (0x5a): YYMMDDHHMMSSZ. | ||
| if (copy[i] === 0x17 && copy[i + 1] === 0x0d && copy[i + 14] === 0x5a) { | ||
| seen += 1; | ||
| // The first UTCTime is thisUpdate (skipped by the parser); the second is nextUpdate. | ||
| if (seen === 2) { | ||
| copy.write(month, i + 4, "latin1"); // month occupies the 3rd–4th content bytes | ||
| return copy; | ||
| } | ||
| } | ||
| } | ||
| throw new Error("nextUpdate UTCTime not found in fixture"); | ||
| } |
188 changes: 188 additions & 0 deletions
188
apps/deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.parser.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import crypto from "node:crypto"; | ||
|
|
||
| /** | ||
| * Parses and verifies the AMD KDS Certificate Revocation List (CRL). | ||
| * | ||
| * AMD publishes one CRL per product at `/vcek/v1/{product}/crl` (DER-encoded), issued and signed by | ||
| * that product's ARK (root). It revokes intermediate (ASK) certificates by serial number — revoking | ||
| * an ASK withdraws AMD's trust from every VCEK, and therefore every chip endorsement, beneath it. | ||
| * | ||
| * `node:crypto` exposes no CRL type, so we walk the `CertificateList` structure (RFC 5280 §5.1) by | ||
| * hand: capture the signed `tbsCertList` bytes, the signature, `nextUpdate`, and the revoked serials, | ||
| * then verify the signature with `node:crypto`. AMD signs these CRLs with RSA-PSS/SHA-384 (the ARK is | ||
| * RSA-4096), which is the only algorithm {@link verifyCrlSignature} accepts. | ||
| */ | ||
|
|
||
| export interface ParsedAmdCrl { | ||
| /** DER bytes of `tbsCertList` — exactly the region the signature covers. */ | ||
| readonly tbsDer: Buffer; | ||
| /** Raw signature bytes (BIT STRING contents with the unused-bits octet stripped). */ | ||
| readonly signature: Buffer; | ||
| /** `nextUpdate`, after which the CRL is stale; `null` when the (optional) field is absent. */ | ||
| readonly nextUpdate: Date | null; | ||
| /** Revoked certificate serial numbers, canonicalised via {@link normalizeSerial}. */ | ||
| readonly revokedSerials: ReadonlySet<string>; | ||
| } | ||
|
|
||
| export class AmdCrlParseError extends Error { | ||
| constructor(message: string) { | ||
| super(message); | ||
| this.name = "AmdCrlParseError"; | ||
| } | ||
| } | ||
|
|
||
| const TAG = { | ||
| INTEGER: 0x02, | ||
| BIT_STRING: 0x03, | ||
| SEQUENCE: 0x30, | ||
| UTC_TIME: 0x17, | ||
| GENERALIZED_TIME: 0x18 | ||
| } as const; | ||
|
|
||
| interface Tlv { | ||
| readonly tag: number; | ||
| readonly contentStart: number; | ||
| readonly contentEnd: number; | ||
| /** Offset of the byte after this element — i.e. the start of its sibling. */ | ||
| readonly end: number; | ||
| } | ||
|
|
||
| /** Reads one DER tag-length-value element. AMD CRLs use only low-tag-number, definite-length forms. */ | ||
| function readTlv(der: Buffer, offset: number): Tlv { | ||
| if (offset + 2 > der.length) throw new AmdCrlParseError("Truncated DER element"); | ||
| const tag = der[offset]; | ||
| let length = der[offset + 1]; | ||
| let pos = offset + 2; | ||
| if (length & 0x80) { | ||
| const numBytes = length & 0x7f; | ||
| if (numBytes === 0 || numBytes > 4) throw new AmdCrlParseError("Unsupported DER length encoding"); | ||
| length = 0; | ||
| for (let i = 0; i < numBytes; i++) { | ||
| if (pos >= der.length) throw new AmdCrlParseError("Truncated DER length"); | ||
| length = length * 256 + der[pos++]; | ||
| } | ||
| } | ||
| const contentEnd = pos + length; | ||
| if (contentEnd > der.length) throw new AmdCrlParseError("DER element exceeds buffer"); | ||
| return { tag, contentStart: pos, contentEnd, end: contentEnd }; | ||
| } | ||
|
|
||
| /** Canonicalises a serial (hex) for comparison: uppercase, leading zeros stripped (sign byte / padding). */ | ||
| export function normalizeSerial(hex: string): string { | ||
| const trimmed = hex.toUpperCase().replace(/^0+/, ""); | ||
| return trimmed === "" ? "0" : trimmed; | ||
| } | ||
|
|
||
| function parseAsn1Time(tag: number, content: Buffer): Date { | ||
| const isUtc = tag === TAG.UTC_TIME; | ||
| // RFC 5280 requires the fully-specified Zulu form: YYMMDDHHMMSSZ / YYYYMMDDHHMMSSZ. Reject anything | ||
| // else so a malformed time fails closed (the caller treats a missing freshness bound as unverifiable). | ||
| const pattern = isUtc ? /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/ : /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/; | ||
| const match = pattern.exec(content.toString("latin1")); | ||
| if (!match) throw new AmdCrlParseError("Malformed CRL time"); | ||
| const rawYear = Number(match[1]); | ||
| // RFC 5280: UTCTime years 00–49 map to 2000–2049, 50–99 to 1950–1999. | ||
| const year = isUtc ? (rawYear < 50 ? 2000 + rawYear : 1900 + rawYear) : rawYear; | ||
| const month = Number(match[2]); | ||
| const day = Number(match[3]); | ||
| const hour = Number(match[4]); | ||
| const minute = Number(match[5]); | ||
| const second = Number(match[6]); | ||
| const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); | ||
| // Date.UTC silently rolls over out-of-range parts (month 13 → next year, day 00 → prior month); | ||
| // round-trip the components so such a time is rejected rather than yielding a plausible wrong date. | ||
| if ( | ||
| date.getUTCFullYear() !== year || | ||
| date.getUTCMonth() !== month - 1 || | ||
| date.getUTCDate() !== day || | ||
| date.getUTCHours() !== hour || | ||
| date.getUTCMinutes() !== minute || | ||
| date.getUTCSeconds() !== second | ||
| ) { | ||
| throw new AmdCrlParseError("Malformed CRL time"); | ||
| } | ||
| return date; | ||
| } | ||
|
|
||
| /** | ||
| * Parses a DER-encoded AMD CRL. Walks `tbsCertList` positionally (RFC 5280 §5.1.2): optional version, | ||
| * signature AlgorithmIdentifier, issuer, thisUpdate, optional nextUpdate, optional revokedCertificates. | ||
| */ | ||
| export function parseAmdCrl(der: Buffer): ParsedAmdCrl { | ||
| const certificateList = readTlv(der, 0); | ||
| if (certificateList.tag !== TAG.SEQUENCE) throw new AmdCrlParseError("CRL is not a SEQUENCE"); | ||
|
|
||
| const tbs = readTlv(der, certificateList.contentStart); | ||
| if (tbs.tag !== TAG.SEQUENCE) throw new AmdCrlParseError("tbsCertList is not a SEQUENCE"); | ||
| const tbsDer = der.subarray(certificateList.contentStart, tbs.end); | ||
|
|
||
| const signatureAlgorithm = readTlv(der, tbs.end); | ||
| const signatureValue = readTlv(der, signatureAlgorithm.end); | ||
| if (signatureValue.tag !== TAG.BIT_STRING) throw new AmdCrlParseError("Missing CRL signature"); | ||
| // A BIT STRING's first content octet counts unused trailing bits (0 for a byte-aligned signature). | ||
| const signature = der.subarray(signatureValue.contentStart + 1, signatureValue.contentEnd); | ||
|
|
||
| let cursor = tbs.contentStart; | ||
| const next = (): Tlv | null => (cursor < tbs.contentEnd ? readTlv(der, cursor) : null); | ||
| const skip = (element: Tlv | null): Tlv | null => { | ||
| if (!element) return null; | ||
| cursor = element.end; | ||
| return next(); | ||
| }; | ||
|
|
||
| let field = next(); | ||
| if (field && field.tag === TAG.INTEGER) field = skip(field); // optional version | ||
| field = skip(field); // signature AlgorithmIdentifier | ||
| field = skip(field); // issuer | ||
| field = skip(field); // thisUpdate | ||
|
|
||
| let nextUpdate: Date | null = null; | ||
| if (field && (field.tag === TAG.UTC_TIME || field.tag === TAG.GENERALIZED_TIME)) { | ||
| nextUpdate = parseAsn1Time(field.tag, der.subarray(field.contentStart, field.contentEnd)); | ||
| field = skip(field); | ||
| } | ||
|
|
||
| const revokedSerials = new Set<string>(); | ||
| // The next element is `revokedCertificates` only when it is a SEQUENCE; otherwise it is the absent | ||
| // (empty CRL) case or the `[0]` crlExtensions, neither of which we read. | ||
| if (field && field.tag === TAG.SEQUENCE) { | ||
| let entryPos = field.contentStart; | ||
| while (entryPos < field.contentEnd) { | ||
| const entry = readTlv(der, entryPos); | ||
| const serial = readTlv(der, entry.contentStart); // userCertificate INTEGER (first field) | ||
| revokedSerials.add(normalizeSerial(der.subarray(serial.contentStart, serial.contentEnd).toString("hex"))); | ||
| entryPos = entry.end; | ||
| } | ||
| } | ||
|
|
||
| return { tbsDer, signature, nextUpdate, revokedSerials }; | ||
| } | ||
|
|
||
| /** | ||
| * Verifies the CRL signature against the issuer (the ARK). AMD always signs with RSA-PSS/SHA-384, so | ||
| * that is the only accepted algorithm; any other issuer key or any error → `false`, so an unverifiable | ||
| * CRL is never trusted. | ||
| */ | ||
| export function verifyCrlSignature(crl: ParsedAmdCrl, issuer: crypto.X509Certificate): boolean { | ||
| try { | ||
| const options = { key: issuer.publicKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO }; | ||
| return crypto.verify("sha384", crl.tbsDer, options, crl.signature); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** True when the CRL lists the certificate's serial number as revoked. */ | ||
| export function isCertRevoked(crl: ParsedAmdCrl, cert: crypto.X509Certificate): boolean { | ||
| return crl.revokedSerials.has(normalizeSerial(cert.serialNumber)); | ||
| } | ||
|
|
||
| /** | ||
| * True when the CRL has no usable freshness bound (missing/invalid `nextUpdate`) or `now` is at/past | ||
| * it. Fails closed: a CRL without a verifiable `nextUpdate` is treated as expired (→ revocation unknown). | ||
| */ | ||
| export function isCrlExpired(crl: ParsedAmdCrl, now: Date): boolean { | ||
| // `parseAmdCrl` only ever yields `null` or a valid Date here (parseAsn1Time throws on a bad time), | ||
| // so there is no non-finite case to guard against. | ||
| return crl.nextUpdate === null || now.getTime() >= crl.nextUpdate.getTime(); | ||
| } | ||
40 changes: 40 additions & 0 deletions
40
.../deploy-web/src/lib/nextjs/confidential-compute/services/amd-snp/amd-crl.test-fixtures.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| /** | ||
| * Generates a DER-encoded CRL signed by the ARK in `signerDir` (which must contain `ark.pem` + `ark.key`), | ||
| * revoking the given certificate serials — an empty list yields a clean CRL. Signed RSA-PSS/SHA-384 to | ||
| * mirror AMD KDS, the only algorithm {@link verifyCrlSignature} accepts. Runs in a throwaway temp dir. | ||
| */ | ||
| export function generateCrl(signerDir: string, revokedSerials: string[]): Buffer { | ||
| const dir = mkdtempSync(join(tmpdir(), "amd-crl-")); | ||
| try { | ||
| const ossl = (args: string[]) => execFileSync("openssl", args, { cwd: dir, stdio: ["ignore", "ignore", "pipe"] }); | ||
|
|
||
| const entries = revokedSerials.map(serial => `R\t350101000000Z\t240101000000Z\t${serial}\tunknown\t/CN=SEV-Milan\n`).join(""); | ||
| writeFileSync(join(dir, "index.txt"), entries); | ||
| writeFileSync(join(dir, "crlnumber"), "1000\n"); | ||
| writeFileSync( | ||
| join(dir, "ca.cnf"), | ||
| [ | ||
| "[ca]", | ||
| "default_ca = myca", | ||
| "[myca]", | ||
| `database = ${join(dir, "index.txt")}`, | ||
| `crlnumber = ${join(dir, "crlnumber")}`, | ||
| `certificate = ${join(signerDir, "ark.pem")}`, | ||
| `private_key = ${join(signerDir, "ark.key")}`, | ||
| "default_md = sha384", | ||
| "default_crl_days = 30", | ||
| "" | ||
| ].join("\n") | ||
| ); | ||
| ossl(["ca", "-config", "ca.cnf", "-gencrl", "-out", "crl.pem", "-sigopt", "rsa_padding_mode:pss", "-sigopt", "rsa_pss_saltlen:digest"]); | ||
| ossl(["crl", "-in", "crl.pem", "-outform", "DER", "-out", "crl.der"]); | ||
| return readFileSync(join(dir, "crl.der")); | ||
| } finally { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.