Review and update the manifest verification
- Cache headers with the response
This commit is contained in:
parent
bc1dacefa3
commit
9cbe7775e9
177
src/manifest.ts
177
src/manifest.ts
@ -4,74 +4,26 @@ import { request } from "./utils";
|
|||||||
import { getSigningKey } from "./signing-key";
|
import { getSigningKey } from "./signing-key";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the creation date from an OpenPGP v6 signature object.
|
* Returns the 'Last-Modified' Date only if it is valid and falls
|
||||||
|
* between 'created' and now. Otherwise returns undefined.
|
||||||
*/
|
*/
|
||||||
async function getSignatureDate(signature: any): Promise<Date | null> {
|
export function getValidatedLastModified(
|
||||||
if (null === signature) return null;
|
res: Response,
|
||||||
|
created: Date,
|
||||||
|
): Date | undefined {
|
||||||
|
const headerValue = res.headers.get("Last-Modified");
|
||||||
|
if (!headerValue) return undefined;
|
||||||
|
|
||||||
try {
|
const mtime = new Date(headerValue);
|
||||||
// 1. Official v6 async getter
|
const mtimeNum = mtime.getTime();
|
||||||
if ("function" === typeof signature.getCreationTime) {
|
|
||||||
const date = await signature.getCreationTime();
|
|
||||||
if (date instanceof Date) return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sigPacket = signature.signaturePacket;
|
// 1. Check for 'Invalid Date' (NaN)
|
||||||
if (null !== sigPacket && undefined !== sigPacket) {
|
// 2. Ensure it isn't before the 'created' bound
|
||||||
// 2. Search Hashed Subpackets (Spec Type 2: Signature Creation Time)
|
// 3. Ensure it isn't in the future (server clock drift)
|
||||||
const subpackets = [
|
const isValid =
|
||||||
...(sigPacket.hashedSubpackets || []),
|
!isNaN(mtimeNum) && mtimeNum > created.getTime() && mtimeNum < Date.now();
|
||||||
...(sigPacket.subpackets || []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const creationSub = subpackets.find(
|
return isValid ? mtime : undefined;
|
||||||
(p) => 2 === p?.type || undefined !== p?.creationTime,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
undefined !== creationSub &&
|
|
||||||
creationSub?.creationTime instanceof Date
|
|
||||||
) {
|
|
||||||
return creationSub.creationTime;
|
|
||||||
}
|
|
||||||
// 2b. v6 internal subpacket search (Type 2 is Creation Time)
|
|
||||||
// We convert to Array because v6 subpackets can be an Iterable/Map
|
|
||||||
const hashed = sigPacket.hashedSubpackets || [];
|
|
||||||
const unhashed = sigPacket.unhashedSubpackets || [];
|
|
||||||
const allSubpackets = [...hashed, ...unhashed];
|
|
||||||
|
|
||||||
for (const p of allSubpackets) {
|
|
||||||
if (2 === p?.type && p?.creationTime instanceof Date) {
|
|
||||||
return p.creationTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check for the synchronous 'created' property
|
|
||||||
if (sigPacket.created instanceof Date) {
|
|
||||||
return sigPacket.created;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Try the packet-level getter
|
|
||||||
if ("function" === typeof sigPacket.getCreationTime) {
|
|
||||||
const date = await sigPacket.getCreationTime();
|
|
||||||
if (date instanceof Date) return date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Deep Search in 'packets' array
|
|
||||||
const packets = signature.packets || [];
|
|
||||||
if (Array.isArray(packets)) {
|
|
||||||
for (const p of packets) {
|
|
||||||
if ("function" === typeof p.getCreationTime) {
|
|
||||||
const date = await p.getCreationTime();
|
|
||||||
if (date instanceof Date) return date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,33 +33,37 @@ export async function getVerifiedManifest(
|
|||||||
downloadUrl: string,
|
downloadUrl: string,
|
||||||
token?: string,
|
token?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const ascUrl = `${downloadUrl}.asc`;
|
const parsedUrl = new URL(downloadUrl);
|
||||||
const parsedUrl = new URL(ascUrl);
|
parsedUrl.pathname = `${parsedUrl.pathname}.asc`;
|
||||||
|
const ascUrl = parsedUrl.href;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scoping the token to github.com prevents leaking credentials to
|
* Scoping the token to github.com prevents leaking credentials to
|
||||||
* third-party servers while allowing for higher rate limits and
|
* third-party servers while allowing for higher rate limits and
|
||||||
* access to private repositories.
|
* access to private repositories.
|
||||||
*/
|
*/
|
||||||
const isGitHub = "github.com" === parsedUrl.hostname;
|
const isGitHub =
|
||||||
|
"github.com" === parsedUrl.hostname && "https:" === parsedUrl.protocol;
|
||||||
|
|
||||||
const res = await request(ascUrl, {
|
const res = await request(ascUrl, {
|
||||||
headers: isGitHub && token ? { "Authorization": `Bearer ${token}` } : {},
|
headers: isGitHub && token ? { "Authorization": `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const armoredSignedMessage = await res.text();
|
const [armoredSignedMessage, publicKey] = await Promise.all([
|
||||||
|
res.text(),
|
||||||
/**
|
|
||||||
* We run these in parallel to avoid "waterfalling" the async work:
|
|
||||||
* 1. getSigningKey: Resolves/validates the 'robobun' public key from storage or pool.
|
|
||||||
* 2. readCleartextMessage: Parses the raw string into an OpenPGP message object.
|
|
||||||
*/
|
|
||||||
const [publicKey, message] = await Promise.all([
|
|
||||||
getSigningKey(token),
|
getSigningKey(token),
|
||||||
openpgp.readCleartextMessage({ cleartextMessage: armoredSignedMessage }),
|
|
||||||
]);
|
]);
|
||||||
|
// This must wait for armoredSignedMessage to be available.
|
||||||
|
const cleartextMessage = await openpgp.readCleartextMessage({
|
||||||
|
cleartextMessage: armoredSignedMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = publicKey.getCreationTime();
|
||||||
const fingerprint = publicKey.getFingerprint().toUpperCase();
|
const fingerprint = publicKey.getFingerprint().toUpperCase();
|
||||||
|
const trustedKeyID = publicKey.getKeyID().toHex().toLowerCase();
|
||||||
|
|
||||||
|
info(`Trusted Key ID: ${trustedKeyID}`);
|
||||||
|
info(`Trusted Fingerprint: ${fingerprint}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 'verification' holds a result object that includes the unverified data
|
* 'verification' holds a result object that includes the unverified data
|
||||||
@ -115,34 +71,28 @@ export async function getVerifiedManifest(
|
|||||||
* hasn't been checked yet.
|
* hasn't been checked yet.
|
||||||
*/
|
*/
|
||||||
const verification = await openpgp.verify({
|
const verification = await openpgp.verify({
|
||||||
message,
|
message: cleartextMessage,
|
||||||
verificationKeys: publicKey,
|
verificationKeys: publicKey,
|
||||||
|
date: getValidatedLastModified(res, created) ?? new Date(),
|
||||||
|
expectSigned: true,
|
||||||
format: "utf8",
|
format: "utf8",
|
||||||
});
|
});
|
||||||
|
|
||||||
const trustedKeyID = publicKey.getKeyID().toHex().toLowerCase();
|
|
||||||
|
|
||||||
info(`Trusted Key ID: ${trustedKeyID}`);
|
|
||||||
info(`Trusted Fingerprint: ${fingerprint}`);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter for the signature that matches our trusted robobun fingerprint.
|
* Filter for the signature that matches our trusted robobun fingerprint.
|
||||||
* This ensures we aren't misled by other signatures that might be present.
|
* This ensures we aren't misled by other signatures that might be present.
|
||||||
*/
|
*/
|
||||||
const signature = verification.signatures.find((sig) => {
|
const signature = verification.signatures.find((sig) => {
|
||||||
const sigKeyID = sig.keyID.toHex().toLowerCase();
|
const signingKey = publicKey.getKeys(sig.keyID)[0];
|
||||||
|
if (signingKey && publicKey.hasSameFingerprintAs(signingKey)) {
|
||||||
const sigFingerprint = sig.signingKey?.getFingerprint().toUpperCase();
|
|
||||||
if (
|
|
||||||
(sigFingerprint && sigFingerprint === fingerprint) ||
|
|
||||||
sigKeyID === trustedKeyID
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signingSubkey = publicKey.getCommonKeys(sig.keyID)[0];
|
const signingSubkeys = publicKey.getSubkeys(sig.keyID);
|
||||||
if (signingSubkey) {
|
for (const subKey of signingSubkeys) {
|
||||||
return signingSubkey.getFingerprint().toUpperCase() === fingerprint;
|
if (subKey.mainKey.hasSameFingerprintAs(publicKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@ -156,41 +106,40 @@ export async function getVerifiedManifest(
|
|||||||
* Log the signature details immediately. This allows us to see the
|
* Log the signature details immediately. This allows us to see the
|
||||||
* identity claims before the cryptographic verification is attempted.
|
* identity claims before the cryptographic verification is attempted.
|
||||||
*/
|
*/
|
||||||
// In v6, the creation time might be in the signature object directly
|
|
||||||
// or inside the internal packets.
|
|
||||||
const creationDate = await getSignatureDate(signature);
|
|
||||||
info("Checking PGP signature...");
|
info("Checking PGP signature...");
|
||||||
info(
|
info(` - Key ID\t: ${signature.keyID.toHex().toLowerCase()}`);
|
||||||
`- Signed On: ${creationDate instanceof Date ? creationDate.toISOString() : "Unknown"}`,
|
const signatureKey = publicKey.getKeys(signature.keyID)[0];
|
||||||
);
|
info(` - Fingerprint\t: ${signatureKey.getFingerprint().toUpperCase()}`);
|
||||||
info(`- Key ID: ${signature.keyID.toHex().toLowerCase()}`);
|
|
||||||
info(`- Fingerprint: ${fingerprint}\n`);
|
|
||||||
|
|
||||||
const { verified, data } = signature;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/**
|
/**
|
||||||
* MUST await 'verified' to perform the cryptographic check.
|
* MUST await 'verified' to perform the cryptographic check.
|
||||||
* If the signature is invalid or tampered with, this throws.
|
* If the signature is invalid or tampered with, this throws.
|
||||||
*/
|
*/
|
||||||
await verified;
|
const [verifyObj, sigObj] = await Promise.all([
|
||||||
info("Signature verified successfully.");
|
signature.verified,
|
||||||
|
signature.signature,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const creationDate = sigObj.packets[0]?.created;
|
||||||
|
info(
|
||||||
|
` - Signed On\t: ${creationDate instanceof Date ? creationDate.toISOString() : "Unknown"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (true === verifyObj) {
|
||||||
|
info("\nSignature verified successfully.");
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = (err as Error).message;
|
const errMessage = (err as Error).message;
|
||||||
error(`PGP Signature verification failed: ${message}`);
|
error(`PGP Signature verification failed: ${errMessage}`);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`PGP Signature verification failed for ${ascUrl}: ${message}`,
|
`PGP Signature verification failed for ${ascUrl}: ${errMessage}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (!verification.data) {
|
||||||
* In v6, signature.data is undefined for cleartext.
|
|
||||||
* Use the 'message' object which is the CleartextMessage.
|
|
||||||
*/
|
|
||||||
const text = message.getText();
|
|
||||||
if (!text) {
|
|
||||||
throw new Error("Verified manifest text is empty or undefined.");
|
throw new Error("Verified manifest text is empty or undefined.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return verification.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,22 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { URL } from "node:url";
|
import { URL } from "node:url";
|
||||||
import { getCache, setCache } from "./filesystem-cache";
|
import { getCacheTtl, getCache, setCache } from "./filesystem-cache";
|
||||||
|
|
||||||
|
const ENVELOPE_SENTINEL = "__envelope";
|
||||||
|
|
||||||
|
// Extracts the type of the 'method' property from RequestInit
|
||||||
|
type FetchMethod = NonNullable<RequestInit["method"]>;
|
||||||
|
|
||||||
|
function makeEnvelopeValue(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
status: number,
|
||||||
|
): string {
|
||||||
|
return createHash("sha1")
|
||||||
|
.update(JSON.stringify({ method, url, status }))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the URL is metadata eligible for storage (e.g., GitHub API).
|
* Determines if the URL is metadata eligible for storage (e.g., GitHub API).
|
||||||
@ -35,15 +51,41 @@ export function getStoredResponse(url: string): Response | undefined {
|
|||||||
|
|
||||||
const data = getCache(url);
|
const data = getCache(url);
|
||||||
if (null !== data) {
|
if (null !== data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
"object" === typeof parsed &&
|
||||||
|
ENVELOPE_SENTINEL in parsed &&
|
||||||
|
parsed[ENVELOPE_SENTINEL] ===
|
||||||
|
makeEnvelopeValue(parsed.method, url, parsed.status)
|
||||||
|
) {
|
||||||
|
return new Response(parsed.body, {
|
||||||
|
status: parsed.status,
|
||||||
|
headers: {
|
||||||
|
...parsed.headers,
|
||||||
|
"X-Storage-Hit": "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Not JSON or not an envelope; Fall through to legacy handler */
|
||||||
|
}
|
||||||
|
|
||||||
const host = new URL(url).hostname;
|
const host = new URL(url).hostname;
|
||||||
const contentType =
|
const contentType =
|
||||||
"api.github.com" === host
|
"api.github.com" === host
|
||||||
? "application/json"
|
? "application/json"
|
||||||
: "text/plain; charset=utf-8";
|
: "text/plain; charset=utf-8";
|
||||||
|
// Legacy/Raw Handler: Synthetic Last-Modified
|
||||||
|
// (now - TTL) is the oldest possible age for this data
|
||||||
|
const lastModified = new Date(Date.now() - getCacheTtl()).toUTCString();
|
||||||
return new Response(data, {
|
return new Response(data, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
|
"Last-Modified": lastModified,
|
||||||
"X-Storage-Hit": "true",
|
"X-Storage-Hit": "true",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -57,6 +99,7 @@ export function getStoredResponse(url: string): Response | undefined {
|
|||||||
export async function setStoredResponse(
|
export async function setStoredResponse(
|
||||||
url: string,
|
url: string,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
method: FetchMethod = "GET",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isMetadata(url) || !res.ok) {
|
if (!isMetadata(url) || !res.ok) {
|
||||||
return;
|
return;
|
||||||
@ -65,7 +108,18 @@ export async function setStoredResponse(
|
|||||||
try {
|
try {
|
||||||
// We clone so the original stream remains readable by the caller
|
// We clone so the original stream remains readable by the caller
|
||||||
const body = await res.clone().text();
|
const body = await res.clone().text();
|
||||||
setCache(url, body);
|
const headers = Object.fromEntries(res.headers.entries());
|
||||||
|
const envelope = JSON.stringify({
|
||||||
|
[ENVELOPE_SENTINEL]: makeEnvelopeValue(method, url, res.status),
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
ok: res.ok,
|
||||||
|
method: method,
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
url: res.url,
|
||||||
|
});
|
||||||
|
setCache(url, envelope);
|
||||||
} catch {
|
} catch {
|
||||||
// Fail silently to avoid breaking the main execution flow
|
// Fail silently to avoid breaking the main execution flow
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export async function request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (canUseResponseCache) {
|
if (canUseResponseCache) {
|
||||||
await setStoredResponse(url, res);
|
await setStoredResponse(url, res, (init?.method ?? "GET").toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user