Merge: rename quack to quak (Ente = duck, quak = German for quack)

This commit is contained in:
2026-05-13 18:03:03 -07:00
16 changed files with 56 additions and 56 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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/",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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`);
}

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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
*

View File

@@ -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";

View File

@@ -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:

View File

@@ -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

View File

@@ -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);

View File

@@ -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}"...`);

View File

@@ -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
*

View File

@@ -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);