Merge: rename quack to quak (Ente = duck, quak = German for quack)
This commit is contained in:
2
Makefile
2
Makefile
@@ -33,7 +33,7 @@ clean:
|
||||
@rm -rf dist coverage .vitest-cache *.tsbuildinfo
|
||||
|
||||
docker:
|
||||
docker build -t quack .
|
||||
docker build -t quak .
|
||||
|
||||
hooks:
|
||||
@printf '#!/bin/sh\nset -e\nmake lint\nmake fmt-check\n' > .git/hooks/pre-commit
|
||||
|
||||
60
README.md
60
README.md
@@ -1,6 +1,6 @@
|
||||
# quack
|
||||
# quak
|
||||
|
||||
quack is a WTFPL-licensed TypeScript client library and CLI by
|
||||
quak is a WTFPL-licensed TypeScript client library and CLI by
|
||||
[@sneak](https://sneak.berlin) for the [Ente](https://ente.io) end-to-end
|
||||
encrypted photo hosting service. It logs in, enumerates collections and files,
|
||||
and downloads individual images while decrypting them on the way to disk.
|
||||
@@ -8,29 +8,29 @@ and downloads individual images while decrypting them on the way to disk.
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
git clone https://git.eeqj.de/sneak/quack.git
|
||||
cd quack
|
||||
git clone https://git.eeqj.de/sneak/quak.git
|
||||
cd quak
|
||||
yarn install
|
||||
yarn build
|
||||
|
||||
# Log in (prompts for email, password, and OTP/TOTP if required).
|
||||
# Stores an encrypted session under $XDG_CONFIG_HOME/quack/.
|
||||
yarn quack login
|
||||
# Stores an encrypted session under $XDG_CONFIG_HOME/quak/.
|
||||
yarn quak login
|
||||
|
||||
# List the user's collections (albums).
|
||||
yarn quack collections
|
||||
yarn quak collections
|
||||
|
||||
# List files in a collection.
|
||||
yarn quack files --collection 12345
|
||||
yarn quak files --collection 12345
|
||||
|
||||
# Download and decrypt a single file to ./out/.
|
||||
yarn quack get 67890 --out ./out/
|
||||
yarn quak get 67890 --out ./out/
|
||||
```
|
||||
|
||||
For library use:
|
||||
|
||||
```ts
|
||||
import { Client } from "quack";
|
||||
import { Client } from "quak";
|
||||
|
||||
const client = await Client.fromSavedSession();
|
||||
for (const c of await client.listCollections()) {
|
||||
@@ -49,7 +49,7 @@ Flutter app fails to sync reliably. The web app is heavy. The desktop app is the
|
||||
web app inside a slow Electron wrapper. The Go CLI is the closest thing to a
|
||||
usable tool, but it is awkward to integrate from anything that is not a shell.
|
||||
|
||||
quack is the first step in fixing that. This repo ships a small, correct,
|
||||
quak is the first step in fixing that. This repo ships a small, correct,
|
||||
well-tested implementation of Ente's cryptographic protocol and its read-only
|
||||
API surface, plus a CLI that proves the library is enough to do real work
|
||||
without a UI.
|
||||
@@ -67,7 +67,7 @@ requires the protocol layer to be correct first.
|
||||
|
||||
## Development workflow
|
||||
|
||||
All work on quack is test-driven. No exceptions.
|
||||
All work on quak is test-driven. No exceptions.
|
||||
|
||||
1. Every change starts on a feature branch off `main`.
|
||||
2. The first commit on the branch is the test suite for what is being added or
|
||||
@@ -79,7 +79,7 @@ All work on quack is test-driven. No exceptions.
|
||||
`main` is always green. The Dockerfile runs `make check`, so a red branch
|
||||
cannot pass CI.
|
||||
5. Tests are the canonical API documentation for this library. Every test file
|
||||
is commented thoroughly enough that a reader who has never seen quack can
|
||||
is commented thoroughly enough that a reader who has never seen quak can
|
||||
learn how to use it from the tests alone. Comments explain why a behavior
|
||||
matters, not just what the assertion checks.
|
||||
6. Test fixtures (cryptographic vectors, recorded HTTP responses, sample files)
|
||||
@@ -98,13 +98,13 @@ All work on quack is test-driven. No exceptions.
|
||||
|
||||
## Design
|
||||
|
||||
quack is a TypeScript library with a thin CLI wrapper. The library does the
|
||||
work; the CLI is for humans.
|
||||
quak is a TypeScript library with a thin CLI wrapper. The library does the work;
|
||||
the CLI is for humans.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
quack/
|
||||
quak/
|
||||
src/
|
||||
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
|
||||
api/ HTTP client + typed endpoint wrappers
|
||||
@@ -114,7 +114,7 @@ quack/
|
||||
client.ts high-level Client class assembled from the above
|
||||
index.ts public library exports
|
||||
bin/
|
||||
quack.ts CLI entrypoint (commander.js)
|
||||
quak.ts CLI entrypoint (commander.js)
|
||||
test/ unit + integration tests (vitest)
|
||||
Makefile
|
||||
Dockerfile
|
||||
@@ -169,7 +169,7 @@ A custom API endpoint is configurable for self-hosted servers via the
|
||||
Required request headers on every authenticated call:
|
||||
|
||||
- `X-Auth-Token`: the decrypted auth token from login.
|
||||
- `X-Client-Package`: identifies the client. quack uses `berlin.sneak.quack`.
|
||||
- `X-Client-Package`: identifies the client. quak uses `berlin.sneak.quak`.
|
||||
|
||||
Endpoints used:
|
||||
|
||||
@@ -187,8 +187,8 @@ Endpoints used:
|
||||
|
||||
### Session persistence
|
||||
|
||||
After login, quack writes an encrypted session blob to
|
||||
`$XDG_CONFIG_HOME/quack/session.json` (default `~/.config/quack/session.json`)
|
||||
After login, quak writes an encrypted session blob to
|
||||
`$XDG_CONFIG_HOME/quak/session.json` (default `~/.config/quak/session.json`)
|
||||
containing the auth token, the user's master key, the user's secret key, and the
|
||||
user's email. The session file is itself encrypted with a key derived from a
|
||||
per-machine random value stored in the OS keychain when available, falling back
|
||||
@@ -197,14 +197,14 @@ secret key are never written to disk in cleartext.
|
||||
|
||||
### CLI surface
|
||||
|
||||
- `quack login`: interactive login, writes session.
|
||||
- `quack logout`: deletes the session.
|
||||
- `quack whoami`: prints the logged-in email.
|
||||
- `quack collections`: list collections (id, name, type, file count).
|
||||
- `quack files --collection <id>`: list files in a collection (id, name, type,
|
||||
- `quak login`: interactive login, writes session.
|
||||
- `quak logout`: deletes the session.
|
||||
- `quak whoami`: prints the logged-in email.
|
||||
- `quak collections`: list collections (id, name, type, file count).
|
||||
- `quak files --collection <id>`: list files in a collection (id, name, type,
|
||||
creation time, size).
|
||||
- `quack get <fileID> --out <dir>`: download and decrypt a file.
|
||||
- `quack get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
|
||||
- `quak get <fileID> --out <dir>`: download and decrypt a file.
|
||||
- `quak get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
|
||||
|
||||
All commands accept `--json` for machine-readable output.
|
||||
|
||||
@@ -438,7 +438,7 @@ export interface ApiClientOptions {
|
||||
thumbsOrigin?: string; // default https://thumbnails.ente.io
|
||||
authToken?: string;
|
||||
fetch?: typeof fetch; // injectable for tests
|
||||
userAgent?: string; // default "quack/<version>"
|
||||
userAgent?: string; // default "quak/<version>"
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -481,8 +481,8 @@ export interface Session {
|
||||
}
|
||||
|
||||
export interface SessionStoreOptions {
|
||||
path?: string; // default $XDG_CONFIG_HOME/quack/session.json
|
||||
keychainService?: string; // default "berlin.sneak.quack"
|
||||
path?: string; // default $XDG_CONFIG_HOME/quak/session.json
|
||||
keychainService?: string; // default "berlin.sneak.quak"
|
||||
}
|
||||
|
||||
export class SessionStore {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "quack",
|
||||
"name": "quak",
|
||||
"version": "0.0.0",
|
||||
"description": "TypeScript client library and CLI for the Ente end-to-end encrypted photo service",
|
||||
"license": "WTFPL",
|
||||
"author": "@sneak <https://sneak.berlin>",
|
||||
"homepage": "https://git.eeqj.de/sneak/quack",
|
||||
"homepage": "https://git.eeqj.de/sneak/quak",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.eeqj.de/sneak/quack.git"
|
||||
"url": "https://git.eeqj.de/sneak/quak.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"quack": "./dist/bin/quack.js"
|
||||
"quak": "./dist/bin/quak.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const DEFAULT_API_ORIGIN = "https://api.ente.io";
|
||||
const DEFAULT_FILES_ORIGIN = "https://files.ente.io";
|
||||
const DEFAULT_THUMBS_ORIGIN = "https://thumbnails.ente.io";
|
||||
const CLIENT_PACKAGE = "berlin.sneak.quack";
|
||||
const CLIENT_PACKAGE = "berlin.sneak.quak";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
apiOrigin?: string;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export type Base64 = string;
|
||||
|
||||
// The blob the server returns alongside the auth token after a successful
|
||||
// login. Together with the user's password, this is everything quack needs
|
||||
// login. Together with the user's password, this is everything quak needs
|
||||
// to derive the master key, secret key, and auth token.
|
||||
export interface KeyAttributes {
|
||||
kekSalt: Base64;
|
||||
|
||||
@@ -86,7 +86,7 @@ export class Client {
|
||||
const code = await opts.emailOTP();
|
||||
response = await submitEmailOTP(api, opts.email, code);
|
||||
} else if (challenge.kind === "passkey") {
|
||||
throw new Error("Passkey authentication is not supported by quack");
|
||||
throw new Error("Passkey authentication is not supported by quak");
|
||||
} else {
|
||||
throw new Error(`Unknown login challenge kind`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
|
||||
// Argon2id over (password, salt, opsLimit, memLimit) producing a 32-byte
|
||||
// Key Encryption Key. Parameters come from the server; quack passes them
|
||||
// Key Encryption Key. Parameters come from the server; quak passes them
|
||||
// straight through. memLimit is in bytes, opsLimit is the iteration count.
|
||||
export const deriveKEK = async (
|
||||
password: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for `ApiClient`.
|
||||
*
|
||||
* `ApiClient` is the HTTP layer that every other module in quack calls to
|
||||
* `ApiClient` is the HTTP layer that every other module in quak calls to
|
||||
* reach the Ente server. It handles:
|
||||
*
|
||||
* - Base URL resolution. Production uses `https://api.ente.io` for the
|
||||
@@ -14,7 +14,7 @@
|
||||
* dedicated CDN hosts.
|
||||
*
|
||||
* - Required headers. Every request carries `X-Client-Package`
|
||||
* (`berlin.sneak.quack`). Authenticated requests also carry
|
||||
* (`berlin.sneak.quak`). Authenticated requests also carry
|
||||
* `X-Auth-Token` with the token recovered by `unwrapAuth`.
|
||||
*
|
||||
* - JSON serialization. `getJSON` and `postJSON` handle Accept /
|
||||
@@ -121,7 +121,7 @@ describe("ApiClient defaults", () => {
|
||||
await client.getJSON("/ping");
|
||||
|
||||
const headers = new Headers(calls[0]!.init?.headers as HeadersInit);
|
||||
expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quack");
|
||||
expect(headers.get("X-Client-Package")).toBe("berlin.sneak.quak");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* # Using the quack Client
|
||||
* # Using the quak Client
|
||||
*
|
||||
* This test file is a tutorial. It walks through every operation the
|
||||
* library supports, in the order you would use them in a real program.
|
||||
* Each `it()` block is a self-contained example with commentary
|
||||
* explaining what is happening and why. If you are reading the quack
|
||||
* explaining what is happening and why. If you are reading the quak
|
||||
* source for the first time, start here.
|
||||
*
|
||||
* The tests run against a mock Ente server built from the same SRP and
|
||||
@@ -30,7 +30,7 @@
|
||||
* All you need is the `Client` class:
|
||||
*
|
||||
* ```ts
|
||||
* import { Client } from "quack";
|
||||
* import { Client } from "quak";
|
||||
* ```
|
||||
*
|
||||
* The Client wraps every lower-level module (crypto, auth, api, model,
|
||||
@@ -332,7 +332,7 @@ beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
server = await buildServer();
|
||||
testDir = mkdtempSync(join(tmpdir(), "quack-usage-test-"));
|
||||
testDir = mkdtempSync(join(tmpdir(), "quak-usage-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -344,7 +344,7 @@ afterAll(() => {
|
||||
// The tutorial
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("quack Client usage guide", () => {
|
||||
describe("quak Client usage guide", () => {
|
||||
/**
|
||||
* ## 1. Logging in
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for `crypto.decryptBox` and `crypto.decryptSealed`.
|
||||
*
|
||||
* These cover the two asymmetric-and-symmetric "box" primitives quack uses
|
||||
* These cover the two asymmetric-and-symmetric "box" primitives quak uses
|
||||
* to unwrap key material from Ente:
|
||||
*
|
||||
* - `decryptBox`: secretbox decryption. Used everywhere a small payload
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* authToken = decryptSealed(encryptedToken, publicKey, secretKey)
|
||||
*
|
||||
* Encryption is server-side; quack only ever decrypts.
|
||||
* Encryption is server-side; quak only ever decrypts.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Ente delivers most binary fields as standard base64 strings (with `+`,
|
||||
* `/`, and `=` padding). A few fields, notably the auth token returned by
|
||||
* the login flow, are URL-safe base64 (with `-` and `_` instead of `+` and
|
||||
* `/`, and stripped padding). quack must accept both forms on input and
|
||||
* `/`, and stripped padding). quak must accept both forms on input and
|
||||
* produce the right form on output.
|
||||
*
|
||||
* These tests pin the contract:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* libsodium ships as WebAssembly. The bindings (`libsodium-wrappers-sumo`)
|
||||
* load asynchronously: the runtime must `await sodium.ready` once before any
|
||||
* crypto call is safe. quack hides that detail behind a single
|
||||
* crypto call is safe. quak hides that detail behind a single
|
||||
* `init()` function.
|
||||
*
|
||||
* Every other test in `test/crypto/**` calls `init()` in `beforeAll`. New
|
||||
|
||||
@@ -36,7 +36,7 @@ let testDir: string;
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
testDir = mkdtempSync(join(tmpdir(), "quack-test-"));
|
||||
testDir = mkdtempSync(join(tmpdir(), "quak-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -97,7 +97,7 @@ const mockFetchForBody = (body: Uint8Array) => {
|
||||
describe("downloadFile", () => {
|
||||
it("downloads, decrypts, and writes a single-chunk file", async () => {
|
||||
const plaintext = new TextEncoder().encode(
|
||||
"Hello from quack! This is a test photo payload.",
|
||||
"Hello from quak! This is a test photo payload.",
|
||||
);
|
||||
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||
const { header, ciphertext } = encryptFileBody(plaintext, key);
|
||||
|
||||
@@ -87,7 +87,7 @@ const main = async () => {
|
||||
const { mkdtempSync, statSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const outDir = mkdtempSync(join(tmpdir(), "quack-live-test-"));
|
||||
const outDir = mkdtempSync(join(tmpdir(), "quak-live-test-"));
|
||||
const outPath = `${outDir}/${first.metadata.title}`;
|
||||
|
||||
console.log(`\n Downloading "${first.metadata.title}"...`);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* These two functions turn the raw encrypted JSON blobs the Ente server
|
||||
* returns into the decrypted Collection and EnteFile objects that the
|
||||
* rest of quack works with.
|
||||
* rest of quak works with.
|
||||
*
|
||||
* ## Collection decryption
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VERSION } from "../src/index.js";
|
||||
|
||||
describe("quack", () => {
|
||||
describe("quak", () => {
|
||||
it("exports a version string", () => {
|
||||
expect(typeof VERSION).toBe("string");
|
||||
expect(VERSION.length).toBeGreaterThan(0);
|
||||
|
||||
Reference in New Issue
Block a user