2024-05-15 00:44:34 +02:00
|
|
|
/**
|
|
|
|
|
* Represents an HTTP verb.
|
|
|
|
|
*/
|
|
|
|
|
type HttpVerb =
|
|
|
|
|
| "GET"
|
|
|
|
|
| "POST"
|
|
|
|
|
| "PUT"
|
|
|
|
|
| "DELETE"
|
|
|
|
|
| "PATCH"
|
|
|
|
|
| "OPTIONS"
|
|
|
|
|
| "HEAD";
|
|
|
|
|
|
2024-06-20 00:21:34 +02:00
|
|
|
const checkEvironmentSupport = () => {
|
2024-05-15 00:44:34 +02:00
|
|
|
// Check if WebCrypto is supported
|
2024-06-20 00:21:34 +02:00
|
|
|
if (!globalThis.crypto?.subtle) {
|
2024-05-15 00:44:34 +02:00
|
|
|
throw new Error("WebCrypto is not supported in this environment");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No way to check if Ed25519 is supported, so just return true
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validates the signature of a request.
|
|
|
|
|
* @see https://lysand.org/security/signing
|
|
|
|
|
*/
|
|
|
|
|
export class SignatureValidator {
|
|
|
|
|
/**
|
|
|
|
|
* Creates a new instance of SignatureValidator.
|
2024-06-20 00:21:34 +02:00
|
|
|
* @param publicKey The public key used for signature verification.
|
2024-05-15 00:44:34 +02:00
|
|
|
*/
|
2024-06-20 00:21:34 +02:00
|
|
|
constructor(private publicKey: CryptoKey) {
|
2024-05-15 00:44:34 +02:00
|
|
|
checkEvironmentSupport();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a SignatureValidator instance from a base64-encoded public key.
|
|
|
|
|
* @param base64PublicKey The base64-encoded public key.
|
|
|
|
|
* @returns A Promise that resolves to a SignatureValidator instance.
|
|
|
|
|
* @example
|
|
|
|
|
* const publicKey = "base64PublicKey";
|
|
|
|
|
* const validator = await SignatureValidator.fromStringKey(publicKey);
|
|
|
|
|
*/
|
|
|
|
|
static async fromStringKey(
|
|
|
|
|
base64PublicKey: string,
|
|
|
|
|
): Promise<SignatureValidator> {
|
|
|
|
|
return new SignatureValidator(
|
|
|
|
|
await crypto.subtle.importKey(
|
|
|
|
|
"spki",
|
|
|
|
|
Buffer.from(base64PublicKey, "base64"),
|
|
|
|
|
"Ed25519",
|
|
|
|
|
false,
|
|
|
|
|
["verify"],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validates the signature of a request.
|
|
|
|
|
* @param request The request object to validate.
|
|
|
|
|
* @returns A Promise that resolves to a boolean indicating whether the signature is valid.
|
|
|
|
|
* @throws TypeError if any required headers are missing in the request.
|
|
|
|
|
* @example
|
|
|
|
|
* const request = new Request(); // Should be a Request from a Lysand federation request
|
|
|
|
|
* const isValid = await validator.validate(request);
|
|
|
|
|
*/
|
|
|
|
|
async validate(request: Request): Promise<boolean>;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validates the signature of a request.
|
|
|
|
|
* @param signature The signature string.
|
|
|
|
|
* @param date The date that the request was signed.
|
|
|
|
|
* @param method The HTTP verb.
|
|
|
|
|
* @param url The URL object.
|
|
|
|
|
* @param body The request body.
|
|
|
|
|
* @returns A Promise that resolves to a boolean indicating whether the signature is valid.
|
|
|
|
|
* @throws TypeError if any required parameters are missing or empty.
|
|
|
|
|
* @example
|
|
|
|
|
* const signature = "keyId=\"https://example.com\",algorithm=\"ed25519\",headers=\"(request-target) host date digest\",signature=\"base64Signature\"";
|
|
|
|
|
* const date = new Date("2021-01-01T00:00:00.000Z");
|
|
|
|
|
* const method = "GET";
|
|
|
|
|
* const url = new URL("https://example.com/users/ff54ee40-2ce9-4d2e-86ac-3cd06a1e1480");
|
|
|
|
|
* const body = "{ ... }";
|
|
|
|
|
* const isValid = await validator.validate(signature, date, method, url, body);
|
|
|
|
|
*/
|
|
|
|
|
async validate(
|
|
|
|
|
signature: string,
|
|
|
|
|
date: Date,
|
|
|
|
|
method: HttpVerb,
|
|
|
|
|
url: URL,
|
|
|
|
|
body: string,
|
|
|
|
|
): Promise<boolean>;
|
|
|
|
|
|
|
|
|
|
async validate(
|
|
|
|
|
requestOrSignature: Request | string,
|
|
|
|
|
date?: Date,
|
|
|
|
|
method?: HttpVerb,
|
|
|
|
|
url?: URL,
|
|
|
|
|
body?: string,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
if (requestOrSignature instanceof Request) {
|
|
|
|
|
const signature = requestOrSignature.headers.get("Signature");
|
|
|
|
|
const date = requestOrSignature.headers.get("Date");
|
|
|
|
|
const url = new URL(requestOrSignature.url);
|
|
|
|
|
const body = await requestOrSignature.text();
|
|
|
|
|
const method = requestOrSignature.method as HttpVerb;
|
|
|
|
|
|
|
|
|
|
const missingHeaders = [
|
|
|
|
|
!signature && "Signature",
|
|
|
|
|
!date && "Date",
|
|
|
|
|
!method && "Method",
|
|
|
|
|
!url && "URL",
|
|
|
|
|
!body && "Body",
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
|
|
|
|
|
// Check if all headers are present
|
2024-06-20 00:21:34 +02:00
|
|
|
if (!(signature && date && method && url && body)) {
|
2024-05-15 00:44:34 +02:00
|
|
|
// Say which headers are missing
|
2024-06-20 00:21:34 +02:00
|
|
|
throw new TypeError(
|
2024-05-15 00:44:34 +02:00
|
|
|
`Headers are missing in request: ${missingHeaders.join(
|
|
|
|
|
", ",
|
|
|
|
|
)}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (signature.split("signature=").length < 2) {
|
2024-06-20 00:21:34 +02:00
|
|
|
throw new TypeError(
|
2024-05-15 00:44:34 +02:00
|
|
|
"Invalid Signature header (wrong format or missing signature)",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const extractedSignature = signature
|
|
|
|
|
.split("signature=")[1]
|
|
|
|
|
.replace(/"/g, "");
|
|
|
|
|
|
|
|
|
|
if (!extractedSignature) {
|
2024-06-20 00:21:34 +02:00
|
|
|
throw new TypeError(
|
2024-05-15 00:44:34 +02:00
|
|
|
"Invalid Signature header (wrong format or missing signature)",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.validate(
|
|
|
|
|
extractedSignature,
|
|
|
|
|
new Date(date),
|
|
|
|
|
method as HttpVerb,
|
|
|
|
|
url,
|
|
|
|
|
body,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-20 00:21:34 +02:00
|
|
|
if (!(date && method && url && body)) {
|
|
|
|
|
throw new TypeError(
|
2024-05-15 00:44:34 +02:00
|
|
|
"Missing or empty required parameters: date, method, url or body",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const signature = requestOrSignature;
|
|
|
|
|
|
|
|
|
|
const digest = await crypto.subtle.digest(
|
|
|
|
|
"SHA-256",
|
|
|
|
|
new TextEncoder().encode(body),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const expectedSignedString =
|
|
|
|
|
`(request-target): ${method.toLowerCase()} ${url.pathname}\n` +
|
|
|
|
|
`host: ${url.host}\n` +
|
|
|
|
|
`date: ${date.toISOString()}\n` +
|
|
|
|
|
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
|
|
|
|
|
"base64",
|
|
|
|
|
)}\n`;
|
|
|
|
|
|
|
|
|
|
// Check if signed string is valid
|
|
|
|
|
const isValid = await crypto.subtle.verify(
|
|
|
|
|
"Ed25519",
|
2024-06-20 00:21:34 +02:00
|
|
|
this.publicKey,
|
2024-05-15 00:44:34 +02:00
|
|
|
Buffer.from(signature, "base64"),
|
|
|
|
|
new TextEncoder().encode(expectedSignedString),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return isValid;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Constructs a signature for a request.
|
|
|
|
|
* @see https://lysand.org/security/signing
|
|
|
|
|
*/
|
|
|
|
|
export class SignatureConstructor {
|
|
|
|
|
/**
|
|
|
|
|
* Creates a new instance of SignatureConstructor.
|
|
|
|
|
* @param privateKey The private key used for signature generation.
|
2024-05-15 00:56:59 +02:00
|
|
|
* @param keyId The key ID used for the Signature header.
|
|
|
|
|
* @example
|
|
|
|
|
* const privateKey = // CryptoKey
|
|
|
|
|
* const keyId = "https://example.com/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51";
|
|
|
|
|
* const constructor = new SignatureConstructor(privateKey, keyId);
|
2024-05-15 00:44:34 +02:00
|
|
|
*/
|
|
|
|
|
constructor(
|
|
|
|
|
private privateKey: CryptoKey,
|
|
|
|
|
private keyId: string,
|
|
|
|
|
) {
|
|
|
|
|
checkEvironmentSupport();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a SignatureConstructor instance from a base64-encoded private key.
|
|
|
|
|
* @param base64PrivateKey The base64-encoded private key.
|
2024-05-15 00:56:59 +02:00
|
|
|
* @param keyId The key ID used for the Signature header.
|
2024-05-15 00:44:34 +02:00
|
|
|
* @returns A Promise that resolves to a SignatureConstructor instance.
|
|
|
|
|
* @example
|
|
|
|
|
* const privateKey = "base64PrivateKey";
|
2024-05-15 00:56:59 +02:00
|
|
|
* const keyId = "https://example.com/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51";
|
|
|
|
|
* const constructor = await SignatureConstructor.fromStringKey(privateKey, keyId);
|
2024-05-15 00:44:34 +02:00
|
|
|
*/
|
|
|
|
|
static async fromStringKey(
|
|
|
|
|
base64PrivateKey: string,
|
|
|
|
|
keyId: string,
|
|
|
|
|
): Promise<SignatureConstructor> {
|
|
|
|
|
return new SignatureConstructor(
|
|
|
|
|
await crypto.subtle.importKey(
|
|
|
|
|
"pkcs8",
|
|
|
|
|
Buffer.from(base64PrivateKey, "base64"),
|
|
|
|
|
"Ed25519",
|
|
|
|
|
false,
|
|
|
|
|
["sign"],
|
|
|
|
|
),
|
|
|
|
|
keyId,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Signs a request.
|
|
|
|
|
* @param request The request object to sign.
|
2024-05-24 07:43:09 +02:00
|
|
|
* @returns A Promise that resolves to the signed request, plus the signed string.
|
2024-05-15 00:44:34 +02:00
|
|
|
* @example
|
|
|
|
|
* const request = new Request();
|
2024-05-24 07:43:09 +02:00
|
|
|
* const { request: signedRequest } = await constructor.sign(request);
|
2024-05-15 00:44:34 +02:00
|
|
|
*/
|
2024-05-24 07:43:09 +02:00
|
|
|
async sign(request: Request): Promise<{
|
|
|
|
|
request: Request;
|
|
|
|
|
signedString: string;
|
|
|
|
|
}>;
|
2024-05-15 00:44:34 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Signs a request.
|
|
|
|
|
* @param method The HTTP verb.
|
|
|
|
|
* @param url The URL object.
|
|
|
|
|
* @param body The request body.
|
|
|
|
|
* @param headers The request headers.
|
2024-05-24 07:43:09 +02:00
|
|
|
* @param date The date that the request was signed (optional)
|
|
|
|
|
* @returns A Promise that resolves to the signed headers, and the signed string.
|
2024-05-15 00:44:34 +02:00
|
|
|
* @throws TypeError if any required parameters are missing or empty.
|
|
|
|
|
* @example
|
|
|
|
|
* const method = "GET";
|
|
|
|
|
* const url = new URL("https://example.com");
|
|
|
|
|
* const body = "request body";
|
2024-05-24 07:43:09 +02:00
|
|
|
* const { headers: signedHeaders } = await constructor.sign(method, url, body);
|
2024-05-15 00:44:34 +02:00
|
|
|
*/
|
|
|
|
|
async sign(
|
|
|
|
|
method: HttpVerb,
|
|
|
|
|
url: URL,
|
2024-06-30 10:20:07 +02:00
|
|
|
body?: string,
|
2024-05-15 00:44:34 +02:00
|
|
|
headers?: Headers,
|
2024-05-24 07:43:09 +02:00
|
|
|
date?: Date,
|
|
|
|
|
): Promise<{
|
|
|
|
|
headers: Headers;
|
|
|
|
|
signedString: string;
|
|
|
|
|
}>;
|
2024-05-15 00:44:34 +02:00
|
|
|
|
|
|
|
|
async sign(
|
|
|
|
|
requestOrMethod: Request | HttpVerb,
|
|
|
|
|
url?: URL,
|
|
|
|
|
body?: string,
|
|
|
|
|
headers: Headers = new Headers(),
|
2024-05-24 07:43:09 +02:00
|
|
|
date?: Date,
|
|
|
|
|
): Promise<
|
|
|
|
|
| {
|
|
|
|
|
headers: Headers;
|
|
|
|
|
signedString: string;
|
|
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
request: Request;
|
|
|
|
|
signedString: string;
|
|
|
|
|
}
|
|
|
|
|
> {
|
2024-05-15 00:44:34 +02:00
|
|
|
if (requestOrMethod instanceof Request) {
|
2024-05-17 11:05:58 +02:00
|
|
|
const request = requestOrMethod.clone();
|
|
|
|
|
|
2024-05-24 07:43:09 +02:00
|
|
|
const { headers, signedString } = await this.sign(
|
2024-05-15 00:44:34 +02:00
|
|
|
requestOrMethod.method as HttpVerb,
|
|
|
|
|
new URL(requestOrMethod.url),
|
|
|
|
|
await requestOrMethod.text(),
|
|
|
|
|
requestOrMethod.headers,
|
2024-05-24 07:43:09 +02:00
|
|
|
requestOrMethod.headers.get("Date")
|
|
|
|
|
? new Date(requestOrMethod.headers.get("Date") ?? "")
|
|
|
|
|
: undefined,
|
2024-05-15 00:44:34 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
request.headers.set("Date", headers.get("Date") ?? "");
|
|
|
|
|
request.headers.set("Signature", headers.get("Signature") ?? "");
|
|
|
|
|
|
2024-05-24 07:43:09 +02:00
|
|
|
return { request, signedString };
|
2024-05-15 00:44:34 +02:00
|
|
|
}
|
|
|
|
|
|
2024-06-30 10:20:07 +02:00
|
|
|
if (!(url && headers)) {
|
2024-06-20 00:21:34 +02:00
|
|
|
throw new TypeError(
|
2024-05-15 00:44:34 +02:00
|
|
|
"Missing or empty required parameters: url, body or headers",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-24 07:43:09 +02:00
|
|
|
const finalDate = date?.toISOString() ?? new Date().toISOString();
|
2024-05-15 00:44:34 +02:00
|
|
|
|
|
|
|
|
const digest = await crypto.subtle.digest(
|
|
|
|
|
"SHA-256",
|
2024-06-30 10:20:07 +02:00
|
|
|
new TextEncoder().encode(body ?? ""),
|
2024-05-15 00:44:34 +02:00
|
|
|
);
|
|
|
|
|
|
2024-05-24 07:43:09 +02:00
|
|
|
const signedString =
|
|
|
|
|
`(request-target): ${requestOrMethod.toLowerCase()} ${
|
|
|
|
|
url.pathname
|
|
|
|
|
}\n` +
|
|
|
|
|
`host: ${url.host}\n` +
|
|
|
|
|
`date: ${finalDate}\n` +
|
2024-05-29 01:38:47 +02:00
|
|
|
`digest: SHA-256=${Buffer.from(digest).toString("base64")}\n`;
|
2024-05-24 07:43:09 +02:00
|
|
|
|
2024-05-15 00:44:34 +02:00
|
|
|
const signature = await crypto.subtle.sign(
|
|
|
|
|
"Ed25519",
|
|
|
|
|
this.privateKey,
|
2024-05-24 07:43:09 +02:00
|
|
|
new TextEncoder().encode(signedString),
|
2024-05-15 00:44:34 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
|
|
|
|
|
"base64",
|
|
|
|
|
);
|
|
|
|
|
|
2024-05-24 07:43:09 +02:00
|
|
|
headers.set("Date", finalDate);
|
2024-05-15 00:44:34 +02:00
|
|
|
headers.set(
|
|
|
|
|
"Signature",
|
|
|
|
|
`keyId="${this.keyId}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
|
|
|
|
);
|
|
|
|
|
|
2024-05-24 07:43:09 +02:00
|
|
|
return {
|
|
|
|
|
headers,
|
|
|
|
|
signedString,
|
|
|
|
|
};
|
2024-05-15 00:44:34 +02:00
|
|
|
}
|
|
|
|
|
}
|