Rename quack to quak

German for 'quack', matching the Ente (German for 'duck') naming. All
references updated: package name, CLI binary, X-Client-Package header,
test descriptions, temp dir prefixes, README, Makefile docker tag.
This commit is contained in:
2026-05-13 18:02:55 -07:00
parent f87680cfd4
commit d8a4b0291e
16 changed files with 56 additions and 56 deletions

View File

@@ -33,7 +33,7 @@ clean:
@rm -rf dist coverage .vitest-cache *.tsbuildinfo @rm -rf dist coverage .vitest-cache *.tsbuildinfo
docker: docker:
docker build -t quack . docker build -t quak .
hooks: hooks:
@printf '#!/bin/sh\nset -e\nmake lint\nmake fmt-check\n' > .git/hooks/pre-commit @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 [@sneak](https://sneak.berlin) for the [Ente](https://ente.io) end-to-end
encrypted photo hosting service. It logs in, enumerates collections and files, encrypted photo hosting service. It logs in, enumerates collections and files,
and downloads individual images while decrypting them on the way to disk. 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 ## Getting Started
```bash ```bash
git clone https://git.eeqj.de/sneak/quack.git git clone https://git.eeqj.de/sneak/quak.git
cd quack cd quak
yarn install yarn install
yarn build yarn build
# Log in (prompts for email, password, and OTP/TOTP if required). # Log in (prompts for email, password, and OTP/TOTP if required).
# Stores an encrypted session under $XDG_CONFIG_HOME/quack/. # Stores an encrypted session under $XDG_CONFIG_HOME/quak/.
yarn quack login yarn quak login
# List the user's collections (albums). # List the user's collections (albums).
yarn quack collections yarn quak collections
# List files in a collection. # List files in a collection.
yarn quack files --collection 12345 yarn quak files --collection 12345
# Download and decrypt a single file to ./out/. # Download and decrypt a single file to ./out/.
yarn quack get 67890 --out ./out/ yarn quak get 67890 --out ./out/
``` ```
For library use: For library use:
```ts ```ts
import { Client } from "quack"; import { Client } from "quak";
const client = await Client.fromSavedSession(); const client = await Client.fromSavedSession();
for (const c of await client.listCollections()) { 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 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. 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 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 API surface, plus a CLI that proves the library is enough to do real work
without a UI. without a UI.
@@ -67,7 +67,7 @@ requires the protocol layer to be correct first.
## Development workflow ## 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`. 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 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 `main` is always green. The Dockerfile runs `make check`, so a red branch
cannot pass CI. cannot pass CI.
5. Tests are the canonical API documentation for this library. Every test file 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 learn how to use it from the tests alone. Comments explain why a behavior
matters, not just what the assertion checks. matters, not just what the assertion checks.
6. Test fixtures (cryptographic vectors, recorded HTTP responses, sample files) 6. Test fixtures (cryptographic vectors, recorded HTTP responses, sample files)
@@ -98,13 +98,13 @@ All work on quack is test-driven. No exceptions.
## Design ## Design
quack is a TypeScript library with a thin CLI wrapper. The library does the quak is a TypeScript library with a thin CLI wrapper. The library does the work;
work; the CLI is for humans. the CLI is for humans.
### Layout ### Layout
``` ```
quack/ quak/
src/ src/
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP) crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
api/ HTTP client + typed endpoint wrappers api/ HTTP client + typed endpoint wrappers
@@ -114,7 +114,7 @@ quack/
client.ts high-level Client class assembled from the above client.ts high-level Client class assembled from the above
index.ts public library exports index.ts public library exports
bin/ bin/
quack.ts CLI entrypoint (commander.js) quak.ts CLI entrypoint (commander.js)
test/ unit + integration tests (vitest) test/ unit + integration tests (vitest)
Makefile Makefile
Dockerfile Dockerfile
@@ -169,7 +169,7 @@ A custom API endpoint is configurable for self-hosted servers via the
Required request headers on every authenticated call: Required request headers on every authenticated call:
- `X-Auth-Token`: the decrypted auth token from login. - `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: Endpoints used:
@@ -187,8 +187,8 @@ Endpoints used:
### Session persistence ### Session persistence
After login, quack writes an encrypted session blob to After login, quak writes an encrypted session blob to
`$XDG_CONFIG_HOME/quack/session.json` (default `~/.config/quack/session.json`) `$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 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 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 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 ### CLI surface
- `quack login`: interactive login, writes session. - `quak login`: interactive login, writes session.
- `quack logout`: deletes the session. - `quak logout`: deletes the session.
- `quack whoami`: prints the logged-in email. - `quak whoami`: prints the logged-in email.
- `quack collections`: list collections (id, name, type, file count). - `quak collections`: list collections (id, name, type, file count).
- `quack files --collection <id>`: list files in a collection (id, name, type, - `quak files --collection <id>`: list files in a collection (id, name, type,
creation time, size). creation time, size).
- `quack get <fileID> --out <dir>`: download and decrypt a file. - `quak get <fileID> --out <dir>`: download and decrypt a file.
- `quack get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail. - `quak get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
All commands accept `--json` for machine-readable output. All commands accept `--json` for machine-readable output.
@@ -438,7 +438,7 @@ export interface ApiClientOptions {
thumbsOrigin?: string; // default https://thumbnails.ente.io thumbsOrigin?: string; // default https://thumbnails.ente.io
authToken?: string; authToken?: string;
fetch?: typeof fetch; // injectable for tests fetch?: typeof fetch; // injectable for tests
userAgent?: string; // default "quack/<version>" userAgent?: string; // default "quak/<version>"
} }
export class ApiError extends Error { export class ApiError extends Error {
@@ -481,8 +481,8 @@ export interface Session {
} }
export interface SessionStoreOptions { export interface SessionStoreOptions {
path?: string; // default $XDG_CONFIG_HOME/quack/session.json path?: string; // default $XDG_CONFIG_HOME/quak/session.json
keychainService?: string; // default "berlin.sneak.quack" keychainService?: string; // default "berlin.sneak.quak"
} }
export class SessionStore { export class SessionStore {

View File

@@ -1,19 +1,19 @@
{ {
"name": "quack", "name": "quak",
"version": "0.0.0", "version": "0.0.0",
"description": "TypeScript client library and CLI for the Ente end-to-end encrypted photo service", "description": "TypeScript client library and CLI for the Ente end-to-end encrypted photo service",
"license": "WTFPL", "license": "WTFPL",
"author": "@sneak <https://sneak.berlin>", "author": "@sneak <https://sneak.berlin>",
"homepage": "https://git.eeqj.de/sneak/quack", "homepage": "https://git.eeqj.de/sneak/quak",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.eeqj.de/sneak/quack.git" "url": "https://git.eeqj.de/sneak/quak.git"
}, },
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"bin": { "bin": {
"quack": "./dist/bin/quack.js" "quak": "./dist/bin/quak.js"
}, },
"files": [ "files": [
"dist/", "dist/",

View File

@@ -1,7 +1,7 @@
const DEFAULT_API_ORIGIN = "https://api.ente.io"; const DEFAULT_API_ORIGIN = "https://api.ente.io";
const DEFAULT_FILES_ORIGIN = "https://files.ente.io"; const DEFAULT_FILES_ORIGIN = "https://files.ente.io";
const DEFAULT_THUMBS_ORIGIN = "https://thumbnails.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 { export interface ApiClientOptions {
apiOrigin?: string; apiOrigin?: string;

View File

@@ -3,7 +3,7 @@
export type Base64 = string; export type Base64 = string;
// The blob the server returns alongside the auth token after a successful // 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. // to derive the master key, secret key, and auth token.
export interface KeyAttributes { export interface KeyAttributes {
kekSalt: Base64; kekSalt: Base64;

View File

@@ -86,7 +86,7 @@ export class Client {
const code = await opts.emailOTP(); const code = await opts.emailOTP();
response = await submitEmailOTP(api, opts.email, code); response = await submitEmailOTP(api, opts.email, code);
} else if (challenge.kind === "passkey") { } 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 { } else {
throw new Error(`Unknown login challenge kind`); throw new Error(`Unknown login challenge kind`);
} }

View File

@@ -1,7 +1,7 @@
import sodium from "libsodium-wrappers-sumo"; import sodium from "libsodium-wrappers-sumo";
// Argon2id over (password, salt, opsLimit, memLimit) producing a 32-byte // 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. // straight through. memLimit is in bytes, opsLimit is the iteration count.
export const deriveKEK = async ( export const deriveKEK = async (
password: string, password: string,

View File

@@ -1,7 +1,7 @@
/** /**
* Tests for `ApiClient`. * 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: * reach the Ente server. It handles:
* *
* - Base URL resolution. Production uses `https://api.ente.io` for the * - Base URL resolution. Production uses `https://api.ente.io` for the
@@ -14,7 +14,7 @@
* dedicated CDN hosts. * dedicated CDN hosts.
* *
* - Required headers. Every request carries `X-Client-Package` * - 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`. * `X-Auth-Token` with the token recovered by `unwrapAuth`.
* *
* - JSON serialization. `getJSON` and `postJSON` handle Accept / * - JSON serialization. `getJSON` and `postJSON` handle Accept /
@@ -121,7 +121,7 @@ describe("ApiClient defaults", () => {
await client.getJSON("/ping"); await client.getJSON("/ping");
const headers = new Headers(calls[0]!.init?.headers as HeadersInit); 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 * 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. * library supports, in the order you would use them in a real program.
* Each `it()` block is a self-contained example with commentary * 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. * source for the first time, start here.
* *
* The tests run against a mock Ente server built from the same SRP and * 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: * All you need is the `Client` class:
* *
* ```ts * ```ts
* import { Client } from "quack"; * import { Client } from "quak";
* ``` * ```
* *
* The Client wraps every lower-level module (crypto, auth, api, model, * The Client wraps every lower-level module (crypto, auth, api, model,
@@ -332,7 +332,7 @@ beforeAll(async () => {
await init(); await init();
await sodium.ready; await sodium.ready;
server = await buildServer(); server = await buildServer();
testDir = mkdtempSync(join(tmpdir(), "quack-usage-test-")); testDir = mkdtempSync(join(tmpdir(), "quak-usage-test-"));
}); });
afterAll(() => { afterAll(() => {
@@ -344,7 +344,7 @@ afterAll(() => {
// The tutorial // The tutorial
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("quack Client usage guide", () => { describe("quak Client usage guide", () => {
/** /**
* ## 1. Logging in * ## 1. Logging in
* *

View File

@@ -1,7 +1,7 @@
/** /**
* Tests for `crypto.decryptBox` and `crypto.decryptSealed`. * 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: * to unwrap key material from Ente:
* *
* - `decryptBox`: secretbox decryption. Used everywhere a small payload * - `decryptBox`: secretbox decryption. Used everywhere a small payload
@@ -18,7 +18,7 @@
* *
* authToken = decryptSealed(encryptedToken, publicKey, secretKey) * 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"; import sodium from "libsodium-wrappers-sumo";

View File

@@ -5,7 +5,7 @@
* Ente delivers most binary fields as standard base64 strings (with `+`, * Ente delivers most binary fields as standard base64 strings (with `+`,
* `/`, and `=` padding). A few fields, notably the auth token returned by * `/`, and `=` padding). A few fields, notably the auth token returned by
* the login flow, are URL-safe base64 (with `-` and `_` instead of `+` and * 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. * produce the right form on output.
* *
* These tests pin the contract: * These tests pin the contract:

View File

@@ -3,7 +3,7 @@
* *
* libsodium ships as WebAssembly. The bindings (`libsodium-wrappers-sumo`) * libsodium ships as WebAssembly. The bindings (`libsodium-wrappers-sumo`)
* load asynchronously: the runtime must `await sodium.ready` once before any * 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. * `init()` function.
* *
* Every other test in `test/crypto/**` calls `init()` in `beforeAll`. New * Every other test in `test/crypto/**` calls `init()` in `beforeAll`. New

View File

@@ -36,7 +36,7 @@ let testDir: string;
beforeAll(async () => { beforeAll(async () => {
await init(); await init();
await sodium.ready; await sodium.ready;
testDir = mkdtempSync(join(tmpdir(), "quack-test-")); testDir = mkdtempSync(join(tmpdir(), "quak-test-"));
}); });
afterAll(() => { afterAll(() => {
@@ -97,7 +97,7 @@ const mockFetchForBody = (body: Uint8Array) => {
describe("downloadFile", () => { describe("downloadFile", () => {
it("downloads, decrypts, and writes a single-chunk file", async () => { it("downloads, decrypts, and writes a single-chunk file", async () => {
const plaintext = new TextEncoder().encode( 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 key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key); const { header, ciphertext } = encryptFileBody(plaintext, key);

View File

@@ -87,7 +87,7 @@ const main = async () => {
const { mkdtempSync, statSync } = await import("node:fs"); const { mkdtempSync, statSync } = await import("node:fs");
const { join } = await import("node:path"); const { join } = await import("node:path");
const { tmpdir } = await import("node:os"); 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}`; const outPath = `${outDir}/${first.metadata.title}`;
console.log(`\n Downloading "${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 * These two functions turn the raw encrypted JSON blobs the Ente server
* returns into the decrypted Collection and EnteFile objects that the * returns into the decrypted Collection and EnteFile objects that the
* rest of quack works with. * rest of quak works with.
* *
* ## Collection decryption * ## Collection decryption
* *

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { VERSION } from "../src/index.js"; import { VERSION } from "../src/index.js";
describe("quack", () => { describe("quak", () => {
it("exports a version string", () => { it("exports a version string", () => {
expect(typeof VERSION).toBe("string"); expect(typeof VERSION).toBe("string");
expect(VERSION.length).toBeGreaterThan(0); expect(VERSION.length).toBeGreaterThan(0);