Files
notaquest/index.html
2026-09-27 19:47:36 -04:00

511 lines
20 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notaquest</title>
<script src="https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<meta name="description"
content="Enable ADB on your Meta Quest without creating a developer organization or verifying with a credit card. One-click tool using Web Bluetooth." />
<meta name="keywords"
content="meta quest, quest 3, adb, developer mode, sideload, sidequest, no organization, web bluetooth" />
<!-- Open Graph (social sharing) -->
<meta property="og:title" content="Notaquest - Enable ADB on Meta Quest without a developer org" />
<meta property="og:description"
content="Skip the organization, skip the credit card. Enable ADB on your Quest from a web page." />
<meta property="og:url" content="https://notaquest.owendeed.com" />
<meta property="og:type" content="website" />
<!-- Twitter/X -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Notaquest - Enable ADB on Meta Quest without a developer org" />
<meta name="twitter:description"
content="Skip the organization, skip the credit card. Enable ADB on your Quest from a web page." />
</head>
<body>
<main class="container">
<h1>Notaquest</h1>
<p>
enabling adb on a meta quest (which is just a weird phone btw) is
PAINFUL! you have to get the slow meta horizon app, create an
organization, verify it by adding an authenticator or USING YOUR CREDIT
CARD and finally, being able to enable a thing androids could do in like
8 taps for years. it doesn't have to be a quest to enable adb. meta
still made it a quest for me to make this work for you, and it's gonna
suck, but it'll suck less than the normal way. i have uploaded 2 images
of forks for meta verification, i will update this message if it works
and i can do oauth. update: it did not. you can use the tampermonkey
script to automate it or use devtools and grab a cookie. i'm very sorry.
this seems to work better on mobile than on pc. tested on android with
google chrome. you may need to quickly accept a pairing confirmation on
the device and headset. the tampermonkey script cannot be ran on Android
Chrome since it doesn't support extensions. you can use firefox for it
and swap to Chrome later, use a browser that supports bluetooth and
extensions, or do it on a pc first and swap later. firefox is not
supported for bluetooth :(
</p>
<p>
i apologize for you having to give me the token, this isn't something
you should need to do to own your device, but sadly, it is required. you
can check the html (human friendly code), and you can log network
requests from the site. the only place it goes it to is my site with a
proxy to meta. yeah, i know what you're thinking if you get what that
means. the token goes to my site. i need to for this to work, i'm sorry,
there's a thing below explaining the code. i know i can't prove it, but
it's safe. if you're really paranoid, reset your password or log out of
the page you got the token from to invalidate the token. if i stole your
token i wouldn't be telling you to do this. you can even check if it
logs into your account before you reset it.
</p>
<p>
currently only tested on meta quest 3 but should work on other devices.
make a github issue (link at the bottom) if it doesn't work for any
reason.
</p>
<details>
<summary>why does this use a proxy?</summary>
<p>
meta's api blocks requests from websites (CORS). the proxy forwards
your request to graph.oculus.com and nothing else. nothing is stored
or logged. you can
<a href="https://api.notaquest.owendeed.com" target="_blank">view the live worker source code</a>
or check the network tab yourself. the code is a literal quine
program, if i change it, it will change.
</p>
</details>
<details>
<summary>what is the point?</summary>
<p>
normally, enabling ADB on your meta headset, is a PAIN to do, like the
amount of work rooting a normal phone takes if you're lucky. which is
still bad. you need to create a meta organization, and then verify
your account. verifying means a few options: enabling an authenticator
app for signing in, or using a credit card and putting a temporary
hold on it. that's for enabling the ability to control a device you
bought. and not even full access to it. this site lets you enable it
without any of that. the only annoying part is the meta token, which
sadly i don't think i can bypass. better than nothing i guess?
</p>
</details>
<details>
<summary>but i can't access devtools on my phone!</summary>
<p>
do the tampermonkey script on firefox/a browser on mobile with
extension support and swap to one with bluetooth after! or do the
following if you can do devtools on a pc but don't wanna do
tampermonkey: at the bottom click the generate token qr code button.
this will give you a qr code. scan it on your phone and go to the
link. it will be prefilled with your token so you don't need to type
it :)
</p>
</details>
<details>
<summary>what is tampermonkey and how do i get it?</summary>
<p>
tampermonkey is an extension which lets you install scripts to modify
what websites do, such as automatically grabbing cookies when going to
a site! which is exactly what this site uses it for. if you can, this
is the best way to get a token.
</p>
<details>
<summary>
IMPORTANT: additional setup for (most) chromium based browsers
(required or silently doesn't work)
</summary>
<p>
the following instructions are required for chromium-based browsers
or it will do nothing and not work. if you don't have the final
switch then try it without doing it. if you just see the Meta
account page when you click the get token button, this is your
problem.
</p>
<li>
click the extensions icon (normally in the top right), you should
see the tampermonkey extension
</li>
<li>click the 3 vertical dots and then click manage extension</li>
<li>turn on the "Allow User Scripts"</li>
<li>it should now work</li>
</details>
<br />
<a href="https://www.tampermonkey.net/">tampermonkey website</a><br />
<a href="https://chromewebstore.google.com/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo">chrome (+chromium
based browsers) extension</a>
<br />
<a href="https://addons.mozilla.org/firefox/addon/tampermonkey/">firefox (+firefox based browsers) extension</a>
</details>
<details>
<summary>automated method (tampermonkey required)</summary>
<ol>
<li>
<p>
first off, please make sure tampermonkey is installed. you can
check the dropdown above this one for instructions.
</p>
</li>
<li>
<button onclick="window.open('/grab.user.js')">
install tampermonkey script for automating the token process
(tampermonkey must be installed)
</button>
</li>
<li>
<p>
after clicking install, click the get token automatically button,
which will open a new notaquest tab with your token prefilled.
</p>
</li>
<li>
<button onclick="
window.location.href = 'https://secure.oculus.com/#notaquest'
">
get token automatically (tampermonkey script must be installed)
</button>
</li>
<li>
<p>feel free to uninstall the tampermonkey script afterwards.</p>
</li>
</ol>
</details>
<details>
<summary>manual method (no tampermonkey)</summary>
<ol>
<li>
Log into
<a href="https://developers.meta.com/horizon/manage/" target="_blank">developers.meta.com/horizon/manage/</a>
</li>
<li>Press F12 to open developer tools</li>
<li>Application -> Cookies -> click the site</li>
<li>Find <code>oc_ac_at</code>, double-click the value, copy it</li>
<li>Paste it below</li>
</ol>
</details>
<input id="token" placeholder="paste your Meta token here" style="width: 100%; max-width: 500px" />
<br /><br />
<button onclick="showQR()">
generate prefilled token qr code for mobile
</button>
<br />
<br />
<img id="qr" style="width: 300px; image-rendering: pixelated" />
<br />
<button id="go" onclick="enableADB()">enable adb</button>
<p id="nobt" style="display: none"></p>
<p id="status"></p>
<a href="https://github.com/owenthepuppy/notaquest/issues">report an issue here</a><br />
<a href="https://gitea.owendeed.com/Toasterkitten/notaquest">view the source here</a><br />
<a href="https://owenfeldman.com">a project by owenfeldman.com</a>
</main>
<script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script>
<script>
if (!navigator.bluetooth) {
document.getElementById("go").style.display = "none";
document.getElementById("nobt").style.display = "block";
document.getElementById("nobt").textContent =
"your browser doesn't support web bluetooth. grab your token, generate a qr code below, and scan it on your phone in chrome or another web bluetooth supported device. respect to the firefox people seeing this.";
}
var params = new URLSearchParams(location.search);
if (params.get("token"))
document.getElementById("token").value = params.get("token");
var SERVICE = "0000feb8-0000-1000-8000-00805f9b34fb";
var WRITE_UUID = "7a442881-509c-47fa-ac02-b06a37d9eb76";
function status(msg) {
document.getElementById("status").textContent = msg;
}
function showQR() {
var token = document.getElementById("token").value.trim();
if (!token) {
status("paste your token first");
return;
}
if (!token.startsWith("OC") && !token.startsWith("FRL")) {
status(
"that doesn't look like a meta token. it should start with OC or FRL.",
);
return;
}
var qr = qrcode(0, "L");
qr.addData("https://notaquest.owendeed.com/?token=" + token);
qr.make();
document.getElementById("qr").src = qr.createDataURL();
}
// protobuf helpers
function varint(n) {
var r = [];
do {
var b = n & 0x7f;
n >>>= 7;
if (n > 0) b |= 0x80;
r.push(b);
} while (n > 0);
return r;
}
function varintField(f, v) {
return [(f << 3) | 0].concat(varint(v));
}
function bytesField(f, d) {
return [(f << 3) | 2].concat(varint(d.length)).concat(Array.from(d));
}
function readVarint(buf, off) {
var r = 0,
s = 0;
while (off < buf.length) {
var b = buf[off++];
r |= (b & 0x7f) << s;
if (!(b & 0x80)) break;
s += 7;
}
return [r, off];
}
function parseProto(buf) {
var fields = {},
off = 0;
while (off < buf.length) {
var tag;
[tag, off] = readVarint(buf, off);
var fn = tag >> 3,
wt = tag & 7;
if (wt === 0) {
var val;
[val, off] = readVarint(buf, off);
fields[fn] = val;
} else if (wt === 2) {
var ln;
[ln, off] = readVarint(buf, off);
fields[fn] = buf.slice(off, off + ln);
off += ln;
} else break;
}
return fields;
}
// chunk data with 2-byte framing header
function makeChunks(data) {
var chunks = [];
for (var i = 0; i < data.length; i += 18) {
var slice = data.slice(i, i + 18);
var seq = Math.floor(i / 18);
var last = i + 18 >= data.length;
chunks.push(
new Uint8Array(
[(last ? 0x80 : 0x00) | (seq >> 8), seq & 0xff].concat(
Array.from(slice),
),
),
);
}
return chunks;
}
// send chunked data, then poll-read the response
async function sendAndReceive(writeChar, data) {
for (var c of makeChunks(Array.from(data))) {
await writeChar.writeValueWithoutResponse(c);
await new Promise((r) => setTimeout(r, 100));
}
await new Promise((r) => setTimeout(r, 3000));
var response = [];
for (var i = 0; i < 200; i++) {
await new Promise((r) => setTimeout(r, 100));
var val = new Uint8Array((await writeChar.readValue()).buffer);
if (val.length <= 1 || val[0] === 0xff) break;
response.push(...val.slice(2));
if (val[0] & 0x80) break;
}
return new Uint8Array(response);
}
// HMAC-SHA256 using Web Crypto
async function hmacSHA256(key, message) {
var k = await crypto.subtle.importKey(
"raw",
key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return new Uint8Array(await crypto.subtle.sign("HMAC", k, message));
}
// encrypt a plaintext request with the shared key (nonce + ciphertext)
function encrypt(plaintext, sharedKey) {
var nonce = nacl.randomBytes(24);
var ciphertext = nacl.box.after(plaintext, nonce, sharedKey);
var out = new Uint8Array(24 + ciphertext.length);
out.set(nonce);
out.set(ciphertext, 24);
return out;
}
// decrypt a response (first 24 bytes = nonce, rest = ciphertext)
function decrypt(data, sharedKey) {
return nacl.box.open.after(
data.slice(24),
data.slice(0, 24),
sharedKey,
);
}
async function enableADB() {
try {
var token = document.getElementById("token").value.trim();
if (!token) {
status("paste your token first");
return;
}
if (!token.startsWith("OC") && !token.startsWith("FRL")) {
status(
"that doesn't look like a meta token. it should start with OC or FRL.",
);
return;
}
// 1. get device_secret from meta api
status("fetching device secret...");
var resp = await fetch("https://api.notaquest.owendeed.com", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body:
"access_token=" +
encodeURIComponent(token) +
"&doc_id=25735612872735053&variables=%7B%7D",
});
var json = await resp.json();
var secret = json?.data?.viewer?.user?.device_secret;
if (!secret) {
status(
"couldn't get device secret. is your token valid? sign out of the meta page, sign back in, and grab a new token. reloading might also work.",
);
return;
}
var deviceSecret = new Uint8Array(
secret.match(/.{2}/g).map((b) => parseInt(b, 16)),
);
// 2. connect bluetooth
status("select your quest...");
var device = await navigator.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: [SERVICE],
});
var server = await device.gatt.connect();
var service = await server.getPrimaryService(SERVICE);
var writeChar = await service.getCharacteristic(WRITE_UUID);
// 3. wait for user to accept pairing on quest
status(
"accept the bluetooth pairing on your quest and mobile device if you see it. (just wait 15 seconds if you don't see it)",
);
await new Promise((r) => setTimeout(r, 15000));
// 4. send HELLO
status("connecting to quest...");
var keypair = nacl.box.keyPair();
var helloBody = bytesField(1, keypair.publicKey).concat(
bytesField(2, nacl.randomBytes(16)),
);
var hello = new Uint8Array(
varintField(1, 1).concat(
varintField(2, 1),
varintField(3, 1),
bytesField(4, helloBody),
),
);
var helloResp = await sendAndReceive(writeChar, hello);
if (!helloResp.length) {
status(
"quest didn't respond. it's battery may have died, you didn't accept a confirmation, or it went out of bluetooth range. check each and try again.",
);
return;
}
// 5. parse server key and compute shared secret
var signedData = parseProto(parseProto(parseProto(helloResp)[3])[1]);
var sharedKey = nacl.box.before(signedData[1], keypair.secretKey);
// 6. AUTHENTICATE
status("authenticating...");
var hmac = await hmacSHA256(deviceSecret, signedData[2]);
var authReq = new Uint8Array(
varintField(1, 1).concat(
varintField(2, 2),
varintField(3, 2),
bytesField(4, bytesField(1, hmac)),
),
);
var authRespRaw = await sendAndReceive(
writeChar,
encrypt(authReq, sharedKey),
);
if (!authRespRaw.length) {
status(
"no auth response, did your headset go out of bluetooth range or die? check each and try again.",
);
return;
}
var authResp = parseProto(decrypt(authRespRaw, sharedKey));
if (authResp[2] !== 0) {
status("authentication failed, try again? this shouldn't happen.");
return;
}
// 7. DEV_MODE_SET
status("enabling adb...");
var devReq = new Uint8Array(
varintField(1, 1).concat(
varintField(2, 6001),
varintField(3, 3),
bytesField(4, varintField(1, 1)),
),
);
var devRespRaw = await sendAndReceive(
writeChar,
encrypt(devReq, sharedKey),
);
if (devRespRaw.length) {
var decrypted = decrypt(devRespRaw, sharedKey);
if (decrypted) {
var devResp = parseProto(decrypted);
if (devResp[2] === 0) {
status("done! adb should be enabled. restarting your headset may be required for it to work.");
return;
}
if (devResp[3]) {
var errMsg = new TextDecoder().decode(devResp[3]);
if (errMsg.toLowerCase().includes("internet")) {
status("your quest isn't connected to wifi. connect it to wifi and try again.");
return;
}
status("quest rejected the command in a way i don't understand. do basic debugging, reboot, new token, all the normal stuff. if not working still, send a github issue with this data (it shouldn't have private info): " + errMsg);
return;
}
status("quest responded but said something i don't understand. this is not an issue i have encountered yet. do basic debugging, reboot, new token, all the normal stuff. if not working still, send a github issue with this data (it shouldn't have private info): response fields: " + JSON.stringify(Object.fromEntries(Object.entries(devResp).map(([k, v]) => [k, v instanceof Uint8Array ? Array.from(v).map(b => b.toString(16)).join('') : v]))));
} else {
status("quest responded but decryption failed. something went very wrong, try a new token, if that doesn't work, make a github issue. raw length: " + devRespRaw.length);
}
} else {
status("no response from quest for dev mode command. BLE connection may have dropped, or the Quest refused something silently.");
}
} catch (e) {
status(
"error: " +
e.message +
", this error isn't something i have handling for, report an issue on the link at the bottom of the page with this message.",
);
}
}
</script>
</body>
</html>