Merge initial scaffolding

Initial repo scaffolding per sneak/prompts NEW_REPO_CHECKLIST: WTFPL
LICENSE, REPO_POLICIES.md, editor and prettier dotfiles, JS toolchain
(TypeScript, ESLint, Prettier, Vitest with pinned versions), Makefile,
Dockerfile (node:22-alpine pinned by sha256), Gitea Actions workflow.
make check and make docker both pass.
This commit is contained in:
2026-05-09 21:33:08 +02:00
17 changed files with 2478 additions and 90 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
node_modules
dist
build
coverage
.DS_Store
.env
.env.*

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# TypeScript / build artifacts
dist/
build/
*.tsbuildinfo
coverage/
.nyc_output/
# Vitest
.vitest-cache/
# Environment / secrets
.env
.env.*
*.pem
*.key
# quack runtime data (in case anyone runs the CLI from inside the repo)
.quack/
# Local Claude Code settings (per-developer)
.claude/

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
yarn.lock
dist/
build/
coverage/

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
# node 22-alpine, 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34
RUN apk add --no-cache make
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN make check

13
LICENSE Normal file
View File

@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

41
Makefile Normal file
View File

@@ -0,0 +1,41 @@
.PHONY: test lint fmt fmt-check check build dev clean docker hooks
# Use `timeout` (GNU coreutils) when available so `make test` is hard-capped.
# On macOS without coreutils this is empty and the cap is skipped.
TIMEOUT := $(shell command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
YARN := yarn run
test:
@$(TIMEOUT) $(if $(TIMEOUT),30s,) $(YARN) vitest run --reporter=dot || \
{ echo "--- Rerunning with verbose for details ---"; \
$(YARN) vitest run --reporter=verbose; exit 1; }
lint:
@$(YARN) eslint .
@$(YARN) prettier --check .
fmt:
@$(YARN) prettier --write .
fmt-check:
@$(YARN) prettier --check .
check: test lint fmt-check
build:
@$(YARN) tsc
dev:
@$(YARN) tsc --watch
clean:
@rm -rf dist coverage .vitest-cache *.tsbuildinfo
docker:
docker build -t quack .
hooks:
@printf '#!/bin/sh\nset -e\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Installed pre-commit hook (runs make check)."

557
README.md
View File

@@ -43,16 +43,27 @@ await client.downloadFile(file, "./out/");
## Rationale
Ente is one of very few photo services with a credible end-to-end encryption
story. The official clients (mobile Flutter app, web React app, and Go CLI) are
high quality but each is bound up with a UI or with sync semantics that aren't
useful for scripted access. Pulling individual photos out of an Ente account
from a script (for backup, migration, archival, or programmatic processing) is
awkward without a small TypeScript library that does just the cryptography and
nothing else.
story. The shipping clients (mobile Flutter, web React, desktop Electron, and Go
CLI) work, but they are slow, buggy, and difficult to script against. The
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.
This project exists to provide that library. It is deliberately scoped to read
operations: log in, walk the account, decrypt and save files. Upload, sharing,
deletion, and sync state management are out of scope for the first release.
quack 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.
The longer-term goal of this project is a simple desktop client for Ente, built
on this library in Electron (or a comparable runtime), with two priorities above
everything else: correctness and stability. Performance and simplicity follow
from those. Features will be added only after the protocol layer is correct, the
local cache is reliable, and the UI is responsive on a five-year-old laptop.
This first release is deliberately scoped to read operations: log in, walk the
account, decrypt and save files. Upload, sharing, deletion, and bidirectional
sync are out of scope. Adding them later is straightforward; doing them right
requires the protocol layer to be correct first.
## Design
@@ -85,17 +96,17 @@ quack/
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
required for `crypto_pwhash` / Argon2id). No hand-rolled crypto.
The key hierarchy, derived during login, looks like this:
The key hierarchy, derived during login, is:
1. The user enters their password.
2. Argon2id (`crypto_pwhash`) over the password and a server-issued
`kekSalt`, with server-issued `memLimit` and `opsLimit`, produces a
32-byte Key Encryption Key (KEK).
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
Encryption Key (KEK).
3. SRP login: a 16-byte SRP login subkey is derived from the KEK using
`crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context
`loginctx`. That 16-byte value is the SRP password.
4. After SRP completes (or after email-OTP fallback), the server returns a
blob of "key attributes" plus an encrypted auth token.
4. After SRP completes (or after email-OTP fallback), the server returns a blob
of "key attributes" plus an encrypted auth token.
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
yields the 32-byte master key.
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
@@ -105,12 +116,12 @@ The key hierarchy, derived during login, looks like this:
yields the URL-safe base64 auth token used in `X-Auth-Token` for all
subsequent calls.
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using
the master key (for owned collections). Per-file keys are decrypted with
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
master key (for owned collections). Per-file keys are decrypted with
`crypto_secretbox_open_easy` using the collection key. File metadata is a
secretbox under the file key. File content is a chunked
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a
4 MiB plaintext chunk size and a 17-byte authentication overhead per chunk.
`crypto_secretstream_xchacha20poly1305` stream under the file key, with a 4 MiB
plaintext chunk size and a 17-byte authentication overhead per chunk.
### HTTP API
@@ -121,102 +132,463 @@ Production endpoints:
- Thumbnail CDN: `https://thumbnails.ente.io/?fileID=<id>`
A custom API endpoint is configurable for self-hosted servers via the
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route
through `<endpoint>/files/download/<id>` instead of the dedicated CDN host.
`ENTE_API_ENDPOINT` environment variable. When set, file downloads route through
`<endpoint>/files/download/<id>` instead of the dedicated CDN host.
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. quack uses `berlin.sneak.quack`.
Endpoints used:
- `GET /users/srp/attributes?email=<email>` fetch SRP + KDF parameters.
- `POST /users/srp/create-session` begin SRP handshake.
- `POST /users/srp/verify-session` complete SRP, receive 2FA challenge or
the encrypted token + key attributes.
- `POST /users/ott` and `POST /users/verify-email` email OTP fallback path.
- `POST /users/two-factor/verify` TOTP second factor.
- `GET /collections/v2?sinceTime=<usec>` list collections changed since
- `GET /users/srp/attributes?email=<email>`: fetch SRP and KDF parameters.
- `POST /users/srp/create-session`: begin SRP handshake.
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
encrypted token plus key attributes.
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
- `POST /users/two-factor/verify`: TOTP second factor.
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
microsecond timestamp; pass 0 for a full enumeration.
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>` list
files in a collection; paginate while `hasMore` is true.
- `GET https://files.ente.io/?fileID=<id>` download encrypted file bytes.
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list files in a
collection; paginate while `hasMore` is true.
- `GET https://files.ente.io/?fileID=<id>`: download encrypted file bytes.
### Session persistence
After login, quack writes an encrypted session blob to
`$XDG_CONFIG_HOME/quack/session.json` (default
`~/.config/quack/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 to a key file at mode `0600` in the
same config directory. The master key and secret key are never written to
disk in cleartext.
`$XDG_CONFIG_HOME/quack/session.json` (default `~/.config/quack/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
to a key file at mode `0600` in the same config directory. The master key and
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, creation time, size).
- `quack get <fileID> --out <dir>` download and decrypt a file.
- `quack get-thumb <fileID> --out <dir>` download and decrypt a
thumbnail.
- `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,
creation time, size).
- `quack get <fileID> --out <dir>`: download and decrypt a file.
- `quack get-thumb <fileID> --out <dir>`: download and decrypt a thumbnail.
All commands accept `--json` for machine-readable output.
## API reference
The complete public surface of the library, expressed as TypeScript
declarations. Every exported name is listed here. Anything not listed is
internal.
### Type aliases
```ts
// src/model/types.ts
export type Bytes = Uint8Array;
export type Base64 = string; // standard base64 unless noted
export type Base64URL = string; // URL-safe base64
export type Microseconds = number; // unix epoch microseconds (int64-ish)
```
### Crypto module
```ts
// src/crypto/index.ts
// Lazily initializes libsodium. Safe to call repeatedly; the first call
// performs the init, subsequent calls are no-ops.
export function init(): Promise<void>;
// Argon2id over a UTF-8 password and a 16-byte salt, producing a 32-byte
// key. memLimit is in bytes, opsLimit is the iteration count, both as
// returned by the server in SRP / key attributes.
export function deriveKEK(
password: string,
salt: Bytes,
opsLimit: number,
memLimit: number,
): Promise<Bytes>;
// crypto_kdf_derive_from_key with subkey id 1 and context "loginctx",
// returning the first 16 bytes. Used as the SRP password.
export function deriveLoginSubkey(kek: Bytes): Bytes;
// crypto_secretbox_open_easy. Returns plaintext or throws on auth failure.
export function decryptBox(ciphertext: Bytes, nonce: Bytes, key: Bytes): Bytes;
// crypto_box_seal_open. Used to recover the auth token after login.
export function decryptSealed(
ciphertext: Bytes,
publicKey: Bytes,
secretKey: Bytes,
): Bytes;
// crypto_secretstream_xchacha20poly1305_init_pull. Returned state is opaque
// and threaded through pullStreamChunk.
export function initStreamPull(header: Bytes, key: Bytes): StreamPullState;
// crypto_secretstream_xchacha20poly1305_pull. Tag values follow libsodium's
// constants: 0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL.
export function pullStreamChunk(
state: StreamPullState,
ciphertext: Bytes,
): { plaintext: Bytes; tag: number };
// Convenience helpers. fromBase64 accepts both standard and URL-safe.
export function fromBase64(s: Base64 | Base64URL): Bytes;
export function toBase64(b: Bytes): Base64;
export function toBase64URL(b: Bytes): Base64URL;
// Plaintext chunk size used by Ente for file content streams.
export const STREAM_CHUNK_SIZE: number; // 4 * 1024 * 1024
// Encrypted-chunk overhead: secretstream auth tag (16) + tag byte (1).
export const STREAM_CHUNK_OVERHEAD: number; // 17
export interface StreamPullState {
/* opaque */
}
```
### Auth module
```ts
// src/auth/types.ts
export interface KDFParams {
kekSalt: Base64;
memLimit: number;
opsLimit: number;
}
export interface KeyAttributes {
kekSalt: Base64;
encryptedKey: Base64;
keyDecryptionNonce: Base64;
publicKey: Base64;
encryptedSecretKey: Base64;
secretKeyDecryptionNonce: Base64;
memLimit: number;
opsLimit: number;
masterKeyEncryptedWithRecoveryKey?: Base64;
masterKeyDecryptionNonce?: Base64;
recoveryKeyEncryptedWithMasterKey?: Base64;
recoveryKeyDecryptionNonce?: Base64;
}
export interface SRPAttributes {
srpUserID: string;
srpSalt: Base64;
memLimit: number;
opsLimit: number;
kekSalt: Base64;
isEmailMFAEnabled: boolean;
}
export interface AuthorizationResponse {
id: number; // user ID
keyAttributes?: KeyAttributes;
encryptedToken?: Base64URL; // sealed-box-encrypted to user's pubkey
twoFactorSessionID?: string;
passkeySessionID?: string;
}
export type LoginChallenge =
| { kind: "complete"; response: AuthorizationResponse }
| { kind: "totp"; sessionID: string }
| { kind: "passkey"; sessionID: string }
| { kind: "emailOTP" };
```
```ts
// src/auth/index.ts
// Begin login. Returns a challenge that tells the caller what to do next:
// supply a TOTP code, supply an email OTP, follow a passkey URL, or stop
// because login is already complete.
export function beginLogin(
api: ApiClient,
email: string,
password: string,
): Promise<LoginChallenge>;
// Submit a TOTP code from an authenticator app. Returns the final
// AuthorizationResponse on success.
export function submitTOTP(
api: ApiClient,
sessionID: string,
code: string,
): Promise<AuthorizationResponse>;
// Request and submit an email-delivered one-time code. Two calls, because
// the first triggers email delivery and the second verifies it.
export function requestEmailOTP(api: ApiClient, email: string): Promise<void>;
export function submitEmailOTP(
api: ApiClient,
email: string,
code: string,
): Promise<AuthorizationResponse>;
// Given an AuthorizationResponse and the user's password, decrypt the master
// key, secret key, and auth token. Throws on bad password or tampered data.
export function unwrapAuth(
response: AuthorizationResponse,
password: string,
): Promise<{
masterKey: Bytes;
secretKey: Bytes;
publicKey: Bytes;
token: string; // base64 URL-safe; goes into X-Auth-Token
}>;
```
### Model module
```ts
// src/model/index.ts
export type CollectionType =
| "album"
| "folder"
| "favorites"
| "uncategorized"
| "unknown";
export interface Collection {
id: number;
ownerID: number;
key: Bytes; // decrypted
name: string; // decrypted
type: CollectionType;
updationTime: Microseconds;
isShared: boolean; // true if owner != current user
}
export type FileType = "image" | "video" | "livePhoto" | "unknown";
export interface FileMetadata {
title: string;
fileType: FileType;
creationTime: Microseconds;
modificationTime: Microseconds;
latitude?: number;
longitude?: number;
hash?: string; // base64 of file SHA256
}
export interface FileBlob {
decryptionHeader: Base64;
size?: number; // size of the encrypted body, if known from server
}
export interface EnteFile {
id: number;
collectionID: number;
ownerID: number;
key: Bytes; // decrypted file key
metadata: FileMetadata;
file: FileBlob;
thumbnail: FileBlob;
updationTime: Microseconds;
}
```
### HTTP client
```ts
// src/api/client.ts
export interface ApiClientOptions {
apiOrigin?: string; // default https://api.ente.io
filesOrigin?: string; // default https://files.ente.io
thumbsOrigin?: string; // default https://thumbnails.ente.io
authToken?: string;
fetch?: typeof fetch; // injectable for tests
userAgent?: string; // default "quack/<version>"
}
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
readonly requestID?: string;
readonly body?: unknown;
}
export class ApiClient {
constructor(opts?: ApiClientOptions);
setAuthToken(token: string): void;
clearAuthToken(): void;
getJSON<T>(
path: string,
query?: Record<string, string | number | undefined>,
): Promise<T>;
postJSON<T>(path: string, body: unknown): Promise<T>;
// Streaming download from the file CDN. Caller is responsible for
// consuming the stream.
getFileStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
getThumbnailStream(fileID: number): Promise<ReadableStream<Uint8Array>>;
}
```
### Session
```ts
// src/session/index.ts
export interface Session {
email: string;
userID: number;
token: string; // base64 URL-safe
masterKey: Bytes; // 32 bytes, never serialized in cleartext
secretKey: Bytes; // 32 bytes, never serialized in cleartext
publicKey: Bytes; // 32 bytes
}
export interface SessionStoreOptions {
path?: string; // default $XDG_CONFIG_HOME/quack/session.json
keychainService?: string; // default "berlin.sneak.quack"
}
export class SessionStore {
constructor(opts?: SessionStoreOptions);
load(): Promise<Session | null>;
save(s: Session): Promise<void>;
clear(): Promise<void>;
}
```
### Client
```ts
// src/client.ts
export interface LoginPrompt {
password: () => Promise<string>;
emailOTP?: () => Promise<string>; // called when account uses email OTP
totp?: () => Promise<string>; // called when TOTP is required
}
export interface ClientOptions extends ApiClientOptions {
sessionStore?: SessionStore;
}
export interface DownloadResult {
path: string;
bytesWritten: number;
}
export class Client {
// Static constructors. Both end with a Client that has a populated
// session and a ready ApiClient.
static login(
email: string,
prompt: LoginPrompt,
opts?: ClientOptions,
): Promise<Client>;
static fromSavedSession(opts?: ClientOptions): Promise<Client>;
readonly api: ApiClient;
readonly session: Readonly<Session>;
// Account.
whoami(): { email: string; userID: number };
saveSession(): Promise<void>;
logout(): Promise<void>; // clears session on disk and in memory
// Collections.
listCollections(opts?: { sinceTime?: Microseconds }): Promise<Collection[]>;
getCollection(id: number): Promise<Collection>;
// Files.
listFiles(
collectionID: number,
opts?: { sinceTime?: Microseconds },
): Promise<EnteFile[]>;
getFile(fileID: number): Promise<EnteFile>;
// Downloads. If outPath is omitted, a path is constructed from the
// decrypted metadata title in the current working directory. If outPath
// is a directory, the filename is taken from the metadata title. If
// outPath is a file path, that file is written.
downloadFile(
file: EnteFile | number,
outPath?: string,
): Promise<DownloadResult>;
downloadThumbnail(
file: EnteFile | number,
outPath?: string,
): Promise<DownloadResult>;
}
```
### Public exports (`src/index.ts`)
```ts
export { Client } from "./client";
export { ApiClient, ApiError } from "./api/client";
export { SessionStore } from "./session";
export * from "./model";
export type {
Session,
LoginPrompt,
ClientOptions,
DownloadResult,
} from "./client";
```
## TODO
Phase 1: scaffolding (this commit and the next)
Phase 1: scaffolding
- [x] `git init`, write README
- [ ] Create `initial-scaffolding` feature branch
- [ ] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`,
`.editorconfig`, `.prettierrc`, `.prettierignore`, `.dockerignore`
- [ ] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`,
`docker`, `hooks`, plus `build`, `dev`, `clean`
- [ ] Add `Dockerfile` running `make check` against pinned node image
- [ ] Add `.gitea/workflows/check.yml` running `docker build .`
- [ ] Add `package.json`, `tsconfig.json`, install pinned versions of
`typescript`, `libsodium-wrappers-sumo`, `secure-remote-password`,
`commander`, `vitest`, `prettier`, `eslint`, `@types/node`
- [ ] Smoke test: `make check` and `make docker` both pass
- [x] Create `initial-scaffolding` feature branch
- [x] Add `LICENSE` (WTFPL), `REPO_POLICIES.md`, `.gitignore`, `.editorconfig`,
`.prettierrc`, `.prettierignore`, `.dockerignore`
- [x] Add `Makefile` with `test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`,
`hooks`, plus `build`, `dev`, `clean`
- [x] Add `Dockerfile` running `make check` against pinned node image
- [x] Add `.gitea/workflows/check.yml` running `docker build .`
- [x] Add `package.json`, `tsconfig.json`, pinned dev versions of `typescript`,
`prettier`, `eslint`, `typescript-eslint`, `vitest`, `@types/node` (the
runtime deps `libsodium-wrappers-sumo`, `secure-remote-password`,
`commander`, etc. land with their respective implementation phases)
- [x] Smoke test: `make check` and `make docker` both pass
Phase 2: crypto primitives
- [ ] Wrap libsodium init as an awaitable singleton
- [ ] `deriveKEK(password, kekSalt, memLimit, opsLimit)` (Argon2id)
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`,
16 bytes)
- [ ] `deriveLoginSubkey(kek)` (KDF with subkey id 1, context `loginctx`, 16
bytes)
- [ ] `decryptBox(ciphertext, nonce, key)` for secretbox
- [ ] `decryptSealed(ciphertext, publicKey, secretKey)` for sealed box
- [ ] `decryptStream(reader, header, key, writer)` for chunked secretstream
(4 MiB plaintext chunks, 17-byte overhead)
- [ ] `initStreamPull` and `pullStreamChunk` for chunked secretstream (4 MiB
plaintext chunks, 17-byte overhead)
- [ ] Round-trip tests against vectors generated by libsodium directly
Phase 3: SRP + auth
- [ ] SRP-6a client using `secure-remote-password` with the same group as
the server
- [ ] `loginViaSRP(email, password)` returning either a 2FA challenge or
`KeyAttributes + encryptedToken`
- [ ] `loginViaEmailOTP(email)` for accounts without SRP enabled
- [ ] SRP-6a client using `secure-remote-password` with the same group as the
server
- [ ] `beginLogin(email, password)` returning a `LoginChallenge`
- [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
- [ ] `submitTOTP(sessionID, code)`
- [ ] `unwrapMasterKey(keyAttributes, password)` returning master key,
secret key, public key, and decrypted token
- [ ] `unwrapAuth(response, password)` returning master key, secret key, public
key, and decrypted token
- [ ] Tests against recorded HTTP fixtures
Phase 4: HTTP client + endpoints
- [ ] Tiny fetch wrapper that attaches `X-Auth-Token` and `X-Client-Package`
- [ ] `ApiClient` that attaches `X-Auth-Token` and `X-Client-Package`
- [ ] Typed wrappers for the endpoints listed above
- [ ] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
errors
- [ ] Error type that surfaces the server's error code and request id
- [ ] `ApiError` that surfaces the server's error code and request id
Phase 5: collections and files
@@ -229,18 +601,16 @@ Phase 5: collections and files
Phase 6: download
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts
it chunk by chunk, writes plaintext to `outPath`. Resolves the
filename from the decrypted metadata title when no `outPath` is
supplied.
- [ ] `downloadFile(fileID, outPath)` streams the encrypted body, decrypts it
chunk by chunk, writes plaintext to `outPath`. Resolves the filename from
the decrypted metadata title when no `outPath` is supplied.
- [ ] `downloadThumbnail(fileID, outPath)` for the thumbnail CDN
- [ ] Live integration test against a throwaway Ente account if one is
available
- [ ] Live integration test against a throwaway Ente account if one is available
Phase 7: session persistence
- [ ] Write session blob encrypted with a key from the OS keychain
(`keytar`) or a `0600` keyfile fallback
- [ ] `SessionStore` writing an encrypted session blob with a key from the OS
keychain (`keytar`) or a `0600` keyfile fallback
- [ ] `Client.fromSavedSession()` and `Client.saveSession()`
- [ ] `quack logout` deletes the session and the keychain entry
@@ -248,8 +618,7 @@ Phase 8: CLI
- [ ] `commander`-based CLI that matches the surface in the Design section
- [ ] `--json` output for every command
- [ ] Reasonable progress output for long downloads (only when stdout is a
TTY)
- [ ] Reasonable progress output for long downloads (only when stdout is a TTY)
Phase 9: docs and 1.0
@@ -257,15 +626,23 @@ Phase 9: docs and 1.0
- [ ] All TODO items above checked
- [ ] Tag `v1.0.0`
Phase 10 and beyond: desktop client (separate repo)
- [ ] Spike Electron app skeleton consuming this library
- [ ] Local cache (SQLite) keyed on `(collectionID, fileID, updationTime)`
- [ ] Background sync worker that streams new files into the cache
- [ ] Read-only gallery UI: thumbnails, full-image view, basic search
- [ ] Add upload, delete, and share back into the library before the desktop UI
exposes them
## Source attribution
The cryptographic protocol and wire format implemented here are Ente's, taken
from the Ente open source clients at
<https://github.com/ente-io/ente>. No code is imported or vendored from those
projects; any reference code that is copied is rewritten in TypeScript in
this repository. Protocol fidelity is verified against the upstream
implementations in `web/packages/base/`, `mobile/apps/photos/lib/`, and
`cli/`.
from the Ente open source clients at <https://github.com/ente-io/ente>. No code
is imported or vendored from those projects; any reference code that is copied
is rewritten in TypeScript in this repository. Protocol fidelity is verified
against the upstream implementations in `web/packages/base/`,
`mobile/apps/photos/lib/`, and `cli/`.
## License

358
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,358 @@
---
title: Repository Policies
last_modified: 2026-03-18
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

10
eslint.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["dist/", "node_modules/", "coverage/", ".vitest-cache/"],
},
js.configs.recommended,
...tseslint.configs.recommended,
);

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "quack",
"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",
"repository": {
"type": "git",
"url": "https://git.eeqj.de/sneak/quack.git"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"quack": "./dist/bin/quack.js"
},
"files": [
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"test": "vitest run",
"lint": "eslint .",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "9.38.0",
"@types/node": "22.18.13",
"eslint": "9.38.0",
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.46.2",
"vitest": "2.1.9"
}
}

1
src/index.ts Normal file
View File

@@ -0,0 +1 @@
export const VERSION = "0.0.0";

9
test/smoke.test.ts Normal file
View File

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

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "bin/**/*"]
}

1433
yarn.lock Normal file

File diff suppressed because it is too large Load Diff