feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user