README for first build
This commit is contained in:
9
Makefile
9
Makefile
@@ -23,6 +23,7 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# make # typecheck and bundle into build/
|
# make # typecheck and bundle into build/
|
||||||
|
# make test # run the unit tests
|
||||||
# make dist # build, then zip build/ into logsdu-<version>.zip
|
# make dist # build, then zip build/ into logsdu-<version>.zip
|
||||||
# make clean # remove build/ and the zip
|
# make clean # remove build/ and the zip
|
||||||
#
|
#
|
||||||
@@ -36,10 +37,16 @@ EXT_ID := logsdu
|
|||||||
VERSION := $(shell node -p "require('./package.json').version")
|
VERSION := $(shell node -p "require('./package.json').version")
|
||||||
DIST := $(EXT_ID)-$(VERSION).zip
|
DIST := $(EXT_ID)-$(VERSION).zip
|
||||||
|
|
||||||
.PHONY: all build dist clean check-deps
|
.PHONY: all build test dist clean check-deps
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
|
# Unit tests for the pure logic: the input formatters and the decision that
|
||||||
|
# says whether a page load may press "Entrar". Run straight through Node's
|
||||||
|
# built-in runner and type stripping, so there is no test framework to install.
|
||||||
|
test:
|
||||||
|
node --test "src/**/*.test.ts"
|
||||||
|
|
||||||
# Call the local toolchain directly, so this works regardless of how pnpm is
|
# Call the local toolchain directly, so this works regardless of how pnpm is
|
||||||
# provided (corepack vs standalone). Run "corepack pnpm install" first.
|
# provided (corepack vs standalone). Run "corepack pnpm install" first.
|
||||||
build: check-deps
|
build: check-deps
|
||||||
|
|||||||
173
README.md
Normal file
173
README.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# logsdu
|
||||||
|
|
||||||
|
A Firefox extension that logs you into an academic portal whose sign-in form is
|
||||||
|
three fields -- registration number, birth date and national ID -- rather than
|
||||||
|
the usual user and password.
|
||||||
|
|
||||||
|
That shape defeats password managers. The form is marked `autocomplete="off"`,
|
||||||
|
none of the three inputs is a `password` field, and Bitwarden, Firefox and
|
||||||
|
Chrome all decline to remember it. Since the values never change and the
|
||||||
|
institution does not let you pick a different login method, the only option
|
||||||
|
left is copying three values by hand, every time.
|
||||||
|
|
||||||
|
logsdu stores them once and fills them in. By default it also presses "Entrar",
|
||||||
|
so the normal case is zero clicks.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below).
|
||||||
|
Node 22+ ships `corepack`, so pnpm does not have to be installed globally.
|
||||||
|
|
||||||
|
```
|
||||||
|
corepack pnpm install # first time, or after a dependency change
|
||||||
|
make # typecheck and bundle into build/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load it into Firefox:
|
||||||
|
|
||||||
|
1. Open `about:debugging`
|
||||||
|
2. Click **This Firefox**
|
||||||
|
3. Click **Load Temporary Add-on**
|
||||||
|
4. Select `build/manifest.json`
|
||||||
|
|
||||||
|
Open the extension's options page, fill in the four values, and save.
|
||||||
|
|
||||||
|
A temporary add-on is dropped when Firefox restarts. See "Installing it
|
||||||
|
permanently" for the fix.
|
||||||
|
|
||||||
|
## The four values
|
||||||
|
|
||||||
|
| Field | Example | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
|
||||||
|
| Registration number | `2000101010` | Digits only |
|
||||||
|
| Birth date | `01/01/2000` | Reformatted as you type |
|
||||||
|
| National ID | `000.000.000-00` | Reformatted as you type |
|
||||||
|
|
||||||
|
Paste raw digits if you like -- the options page inserts the separators, because
|
||||||
|
the portal's input masks expect the values in exactly that shape.
|
||||||
|
|
||||||
|
**The portal address is configuration, not code.** No institution is named
|
||||||
|
anywhere in the extension: not in the manifest, not in the source, not in the
|
||||||
|
build output. Be clear about what that does and does not buy you. It stops
|
||||||
|
someone who reads the extension from learning which portal it is for. It does
|
||||||
|
not hide anything from someone who can read the extension's storage -- and that
|
||||||
|
is the same access that would expose your national ID and birth date anyway.
|
||||||
|
|
||||||
|
## How it behaves
|
||||||
|
|
||||||
|
- **Fills and submits** on the login page, with no interaction.
|
||||||
|
- **At most one automatic submit per hour.** After an attempt, the extension
|
||||||
|
drops back to filling only, so a wrong value cannot resubmit itself on every
|
||||||
|
page load and lock you out. Correct the values and save; saving clears the
|
||||||
|
timer, so the next visit tries again immediately.
|
||||||
|
- **Logging out keeps you logged out.** Clicking the portal's logout control
|
||||||
|
suppresses the automatic submit for five minutes -- otherwise the logout
|
||||||
|
redirect lands on the login page and you would be signed straight back in.
|
||||||
|
- When it fills without submitting, a small note at the bottom of the page says
|
||||||
|
why. Click it to dismiss.
|
||||||
|
- The toolbar popup has a **Preencher agora** button that fills without
|
||||||
|
submitting, for when you want to check the values before sending them.
|
||||||
|
- Automatic submission can be turned off entirely in the options.
|
||||||
|
|
||||||
|
## How the values are stored
|
||||||
|
|
||||||
|
In `storage.local`: private to this browser profile, never synced, never sent
|
||||||
|
anywhere. That is the same protection a browser-saved password gets, and it has
|
||||||
|
the same limit -- anyone with your unlocked account can read it. If that is not
|
||||||
|
good enough for your threat model, this extension is the wrong tool.
|
||||||
|
|
||||||
|
## Installing it permanently
|
||||||
|
|
||||||
|
Firefox only keeps unsigned add-ons until the next restart. Two ways around it:
|
||||||
|
|
||||||
|
- **Sign it (recommended).** `make dist` produces `logsdu-<version>.zip`. Upload
|
||||||
|
it to [addons.mozilla.org](https://addons.mozilla.org) as an **unlisted**
|
||||||
|
add-on: signing is automated, nothing is published publicly or reviewed by
|
||||||
|
hand, and you install the signed `.xpi` it hands back.
|
||||||
|
- **Turn signing off.** Firefox Developer Edition, Nightly or ESR will install
|
||||||
|
unsigned add-ons with `xpinstall.signatures.required=false` in `about:config`.
|
||||||
|
Release Firefox ignores that setting.
|
||||||
|
|
||||||
|
## Chrome
|
||||||
|
|
||||||
|
Not yet. The source deliberately avoids anything Firefox-specific: it uses the
|
||||||
|
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build
|
||||||
|
should be a manifest question rather than a rewrite. It has not been tried, so
|
||||||
|
do not assume it works.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
corepack pnpm install
|
||||||
|
make test # unit tests
|
||||||
|
make # typecheck and bundle
|
||||||
|
corepack pnpm run dev # rebuild on change
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
After a rebuild, press **Reload** next to the extension in `about:debugging`.
|
||||||
|
|
||||||
|
`corepack pnpm run dev` watches and rebuilds, including the static files, but
|
||||||
|
Firefox still needs that Reload click to pick anything up.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/manifest.json` | MV3 manifest. Names no site |
|
||||||
|
| `src/content.ts` | Isolated world. Decides whether to act, then delegates |
|
||||||
|
| `src/injected.ts` | Page world. Does the actual filling and clicking |
|
||||||
|
| `src/portal.ts` | Every selector the extension knows about the form |
|
||||||
|
| `src/config.ts` | Stored values, the rate limit and the logout cooldown |
|
||||||
|
| `src/format.ts` | Input normalisers for the three masked fields |
|
||||||
|
| `src/options.*`, `src/popup.*` | The two bits of UI |
|
||||||
|
|
||||||
|
### Why two scripts instead of one
|
||||||
|
|
||||||
|
The portal drives its inputs with Inputmask, which replaces each element's
|
||||||
|
`value` property with its own accessor. A content script assigning
|
||||||
|
`input.value` from the isolated world writes through Xrays to the *native*
|
||||||
|
setter and skips that accessor: the field looks right on screen, but the mask's
|
||||||
|
buffer is unchanged, and the page's submit handler reads the stale buffer back
|
||||||
|
out through jQuery. So the filling happens inside the page, through the page's
|
||||||
|
own jQuery and Inputmask.
|
||||||
|
|
||||||
|
Submitting is a real click on the button, never `form.submit()`. The portal
|
||||||
|
intercepts the submit event, cancels it, and posts by AJAX with a CSRF token
|
||||||
|
taken from a meta tag. `form.submit()` would bypass that handler and lose the
|
||||||
|
token; a click reproduces exactly what a person pressing "Entrar" does.
|
||||||
|
|
||||||
|
### Why the content script matches every URL
|
||||||
|
|
||||||
|
The address is configured at runtime, so it cannot also be a manifest match
|
||||||
|
pattern -- putting it there is exactly what would name the institution in the
|
||||||
|
shipped code. The script therefore loads everywhere and stops immediately
|
||||||
|
unless the page's origin equals the configured one. Origins are compared whole,
|
||||||
|
so `portal.example.br.evil.tld` does not match.
|
||||||
|
|
||||||
|
The honest cost: the extension holds read access to every page you visit. The
|
||||||
|
alternative -- registering the content script at runtime with
|
||||||
|
`scripting.registerContentScripts` and an optional host permission -- avoids
|
||||||
|
that at the price of a background script and a permission prompt.
|
||||||
|
|
||||||
|
### Why pnpm, not npm
|
||||||
|
|
||||||
|
**Do not run `npm install` or `npm ci` in this repo.** This project is developed
|
||||||
|
on **ZFS**, and npm's installer renames many directories in parallel while
|
||||||
|
hoisting and deduping, which ZFS intermittently fails with:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm error code ENOTEMPTY
|
||||||
|
npm error syscall rename
|
||||||
|
```
|
||||||
|
|
||||||
|
pnpm hard-links packages from a global content-addressable store and does not
|
||||||
|
perform that rename dance, so it is unaffected. Only `npm install` / `npm ci`
|
||||||
|
are the problem -- running *scripts* through npm is fine, since that just spawns
|
||||||
|
tsc and esbuild. The lockfile is `pnpm-lock.yaml`; there is no
|
||||||
|
`package-lock.json` and none should be created.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later. See `LICENSE`.
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"packageManager": "pnpm@9.15.9",
|
"packageManager": "pnpm@9.15.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev": "node esbuild.config.mjs",
|
||||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"test": "node --test \"src/**/*.test.ts\"",
|
||||||
|
"test:watch": "node --test --watch \"src/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// * rcb@beco.cc *
|
// * rcb@beco.cc *
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
|
|
||||||
import { toOrigin } from './format';
|
import { toOrigin } from './format.ts';
|
||||||
|
|
||||||
const CONFIG_KEY = 'config';
|
const CONFIG_KEY = 'config';
|
||||||
const STATE_KEY = 'state';
|
const STATE_KEY = 'state';
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ import {
|
|||||||
loadState,
|
loadState,
|
||||||
matchesSite,
|
matchesSite,
|
||||||
saveState,
|
saveState,
|
||||||
} from './config';
|
} from './config.ts';
|
||||||
import type { Config } from './config';
|
import type { Config } from './config.ts';
|
||||||
import { EVENT_RESULT, FORM, LOGOUT } from './portal';
|
import { EVENT_RESULT, FORM, LOGOUT } from './portal.ts';
|
||||||
import type { FillRequest, FillResult } from './portal';
|
import type { FillRequest, FillResult } from './portal.ts';
|
||||||
|
|
||||||
const FORM_WAIT_MS = 10000;
|
const FORM_WAIT_MS = 10000;
|
||||||
const NOTICE_ID = 'logsdu-notice';
|
const NOTICE_ID = 'logsdu-notice';
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function isCompleteCpf(value: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Origin of a user-typed site address, or null if it cannot be parsed.
|
* Origin of a user-typed site address, or null if it cannot be parsed.
|
||||||
* Accepts input without a scheme ("saladigital.example.com") by assuming
|
* Accepts input without a scheme ("portal.example.br") by assuming
|
||||||
* https, which is what someone pasting an address from the URL bar expects.
|
* https, which is what someone pasting an address from the URL bar expects.
|
||||||
*/
|
*/
|
||||||
export function toOrigin(value: string): string | null {
|
export function toOrigin(value: string): string | null {
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
// before that handler exists would trigger a plain browser form POST without
|
// before that handler exists would trigger a plain browser form POST without
|
||||||
// the CSRF header, which fails.
|
// the CSRF header, which fails.
|
||||||
|
|
||||||
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal';
|
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts';
|
||||||
import type { FillRequest, FillResult } from './portal';
|
import type { FillRequest, FillResult } from './portal.ts';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 100;
|
const POLL_INTERVAL_MS = 100;
|
||||||
const POLL_TIMEOUT_MS = 15000;
|
const POLL_TIMEOUT_MS = 15000;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// * rcb@beco.cc *
|
// * rcb@beco.cc *
|
||||||
// *************************************************************************
|
// *************************************************************************
|
||||||
|
|
||||||
import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config';
|
import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts';
|
||||||
import {
|
import {
|
||||||
formatCpf,
|
formatCpf,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
isCompleteDate,
|
isCompleteDate,
|
||||||
isCompleteRa,
|
isCompleteRa,
|
||||||
toOrigin,
|
toOrigin,
|
||||||
} from './format';
|
} from './format.ts';
|
||||||
|
|
||||||
function el<T extends HTMLElement>(id: string): T {
|
function el<T extends HTMLElement>(id: string): T {
|
||||||
const found = document.getElementById(id);
|
const found = document.getElementById(id);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
// the automatic submit is switched off, rate limited, or suppressed after a
|
// the automatic submit is switched off, rate limited, or suppressed after a
|
||||||
// logout.
|
// logout.
|
||||||
|
|
||||||
import { isConfigured, loadConfig, matchesSite } from './config';
|
import { isConfigured, loadConfig, matchesSite } from './config.ts';
|
||||||
|
|
||||||
const state = document.getElementById('state') as HTMLParagraphElement;
|
const state = document.getElementById('state') as HTMLParagraphElement;
|
||||||
const detail = document.getElementById('detail') as HTMLParagraphElement;
|
const detail = document.getElementById('detail') as HTMLParagraphElement;
|
||||||
|
|||||||
@@ -8,12 +8,15 @@
|
|||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"lib": ["ES2021", "DOM"]
|
"lib": ["ES2021", "DOM"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user