219 lines
7.0 KiB
TypeScript
219 lines
7.0 KiB
TypeScript
export type DeviceIdentity = {
|
|
ed25519Public: string;
|
|
x25519Public: string;
|
|
fingerprint: string;
|
|
};
|
|
|
|
const PAIRING_ID = /^pair_[0-9a-f]{32}$/;
|
|
const PAIRING_CODE = /^[0-9A-F]{20}$/;
|
|
const PUBLIC_KEY = /^[A-Za-z0-9_-]{43}$/;
|
|
const ED25519_SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
|
|
const HEX_64 = /^[0-9a-f]{64}$/;
|
|
const REGISTRATION_PROOF_DOMAIN = new TextEncoder().encode(
|
|
"nekonest-cloud/device-registration-proof/v1",
|
|
);
|
|
|
|
function bytesToHex(bytes: Uint8Array): string {
|
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array {
|
|
const result = new Uint8Array(left.length + right.length);
|
|
result.set(left);
|
|
result.set(right, left.length);
|
|
return result;
|
|
}
|
|
|
|
function decodeBase64Url(value: string): Uint8Array {
|
|
if (!PUBLIC_KEY.test(value)) throw new Error("invalid_public_key");
|
|
const base64 = `${value.replaceAll("-", "+").replaceAll("_", "/")}=`;
|
|
let decoded: string;
|
|
try {
|
|
decoded = atob(base64);
|
|
} catch {
|
|
throw new Error("invalid_public_key");
|
|
}
|
|
const bytes = Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
|
if (bytes.length !== 32) throw new Error("invalid_public_key");
|
|
return bytes;
|
|
}
|
|
|
|
function decodeEd25519Signature(value: string): Uint8Array {
|
|
if (!ED25519_SIGNATURE.test(value)) throw new Error("invalid_registration_proof");
|
|
const base64 = `${value.replaceAll("-", "+").replaceAll("_", "/")}==`;
|
|
let decoded: string;
|
|
try {
|
|
decoded = atob(base64);
|
|
} catch {
|
|
throw new Error("invalid_registration_proof");
|
|
}
|
|
const bytes = Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
|
if (bytes.length !== 64) throw new Error("invalid_registration_proof");
|
|
return bytes;
|
|
}
|
|
|
|
function lengthPrefixed(value: string): Uint8Array {
|
|
const encoded = new TextEncoder().encode(value);
|
|
const result = new Uint8Array(4 + encoded.length);
|
|
new DataView(result.buffer).setUint32(0, encoded.length, false);
|
|
result.set(encoded, 4);
|
|
return result;
|
|
}
|
|
|
|
export function deviceRegistrationProofTranscript(input: {
|
|
bootstrapToken: string;
|
|
os: string;
|
|
ed25519Public: string;
|
|
x25519Public: string;
|
|
identityFingerprint: string;
|
|
transportMode: string;
|
|
}): Uint8Array {
|
|
const fields = [
|
|
input.bootstrapToken.trim(),
|
|
input.os.trim().toLowerCase(),
|
|
input.ed25519Public.trim(),
|
|
input.x25519Public.trim(),
|
|
input.identityFingerprint.trim().toLowerCase(),
|
|
input.transportMode.trim(),
|
|
].map(lengthPrefixed);
|
|
const totalLength = fields.reduce(
|
|
(total, field) => total + field.length,
|
|
REGISTRATION_PROOF_DOMAIN.length,
|
|
);
|
|
const transcript = new Uint8Array(totalLength);
|
|
transcript.set(REGISTRATION_PROOF_DOMAIN);
|
|
let offset = REGISTRATION_PROOF_DOMAIN.length;
|
|
for (const field of fields) {
|
|
transcript.set(field, offset);
|
|
offset += field.length;
|
|
}
|
|
return transcript;
|
|
}
|
|
|
|
export async function verifyDeviceRegistrationProof(input: {
|
|
bootstrapToken: string;
|
|
os: string;
|
|
ed25519Public: string;
|
|
x25519Public: string;
|
|
identityFingerprint: string;
|
|
transportMode: string;
|
|
registrationProof: string;
|
|
}): Promise<boolean> {
|
|
try {
|
|
const publicKey = decodeBase64Url(input.ed25519Public.trim());
|
|
const signature = decodeEd25519Signature(input.registrationProof.trim());
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
Uint8Array.from(publicKey).buffer,
|
|
{ name: "Ed25519" },
|
|
false,
|
|
["verify"],
|
|
);
|
|
return crypto.subtle.verify(
|
|
{ name: "Ed25519" },
|
|
key,
|
|
Uint8Array.from(signature).buffer,
|
|
Uint8Array.from(deviceRegistrationProofTranscript(input)).buffer,
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function normalizePairingCode(value: string): string {
|
|
const normalized = value.trim().toUpperCase();
|
|
if (!PAIRING_CODE.test(normalized)) throw new Error("invalid_pairing_code");
|
|
return normalized;
|
|
}
|
|
|
|
export function parseBootstrapToken(value: string): {
|
|
pairingId: string;
|
|
code: string;
|
|
} {
|
|
const trimmed = value.trim();
|
|
const separator = trimmed.indexOf(".");
|
|
if (separator < 0 || separator !== trimmed.lastIndexOf(".")) {
|
|
throw new Error("invalid_bootstrap_token");
|
|
}
|
|
const pairingId = trimmed.slice(0, separator);
|
|
if (!PAIRING_ID.test(pairingId)) throw new Error("invalid_bootstrap_token");
|
|
return {
|
|
pairingId,
|
|
code: normalizePairingCode(trimmed.slice(separator + 1)),
|
|
};
|
|
}
|
|
|
|
export async function sha256Hex(value: string | Uint8Array): Promise<string> {
|
|
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
|
|
const input = Uint8Array.from(bytes).buffer;
|
|
return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", input)));
|
|
}
|
|
|
|
export function constantTimeEqualHex(left: string, right: string): boolean {
|
|
const leftNormalized = left.toLowerCase();
|
|
const rightNormalized = right.toLowerCase();
|
|
let difference = leftNormalized.length ^ rightNormalized.length;
|
|
const length = Math.max(leftNormalized.length, rightNormalized.length);
|
|
for (let index = 0; index < length; index += 1) {
|
|
difference |=
|
|
(leftNormalized.charCodeAt(index) || 0) ^
|
|
(rightNormalized.charCodeAt(index) || 0);
|
|
}
|
|
return difference === 0;
|
|
}
|
|
|
|
export async function validateDeviceIdentity(input: {
|
|
ed25519Public: string;
|
|
x25519Public: string;
|
|
identityFingerprint: string;
|
|
}): Promise<DeviceIdentity> {
|
|
const ed25519Public = input.ed25519Public.trim();
|
|
const x25519Public = input.x25519Public.trim();
|
|
const fingerprint = input.identityFingerprint.trim().toLowerCase();
|
|
const ed25519Bytes = decodeBase64Url(ed25519Public);
|
|
const x25519Bytes = decodeBase64Url(x25519Public);
|
|
if (!HEX_64.test(fingerprint)) throw new Error("invalid_identity_fingerprint");
|
|
const expected = await sha256Hex(concatBytes(ed25519Bytes, x25519Bytes));
|
|
if (!constantTimeEqualHex(expected, fingerprint)) {
|
|
throw new Error("invalid_identity_fingerprint");
|
|
}
|
|
return { ed25519Public, x25519Public, fingerprint };
|
|
}
|
|
|
|
export function randomDeviceToken(): string {
|
|
return bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
}
|
|
|
|
export function rateWindowStart(now: Date): string {
|
|
const windowMilliseconds = 10 * 60_000;
|
|
return new Date(
|
|
Math.floor(now.getTime() / windowMilliseconds) * windowMilliseconds,
|
|
).toISOString();
|
|
}
|
|
|
|
export async function sourceFingerprint(
|
|
rootSecret: string,
|
|
source: string,
|
|
): Promise<string> {
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(rootSecret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const signature = await crypto.subtle.sign(
|
|
"HMAC",
|
|
key,
|
|
new TextEncoder().encode(`pairing-source-v1\0${source}`),
|
|
);
|
|
return bytesToHex(new Uint8Array(signature));
|
|
}
|
|
|
|
export function requestSource(request: Request, production: boolean): string {
|
|
const edgeSource = request.headers.get("cf-connecting-ip")?.trim() ?? "";
|
|
if (/^[0-9A-Fa-f:.]{3,64}$/.test(edgeSource)) return edgeSource.toLowerCase();
|
|
if (production) throw new Error("trusted_source_unavailable");
|
|
return "local-development";
|
|
}
|