Compare commits
4 Commits
v4.11.1
...
96c20cb4b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c20cb4b2 | |||
| b44acf867b | |||
| 8c67a00f37 | |||
| 306bf08452 |
13
.scaffold/log.md
Normal file
13
.scaffold/log.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Logg — Shade
|
||||||
|
|
||||||
|
## 2026-07-09 — Adoptert Scaffold plan-kontrakt
|
||||||
|
Migrerte `docs/ROADMAP.md` og `android/shade-android/ROADMAP-ANDROID.md` til `.scaffold/`: nåbildet destillert inn i `plan.md`, originalene arkivert under `.scaffold/archive/` med opprinnelig mappestruktur. Versjonsdesigndokumentene (`docs/V5.0.md`, `docs/archive/V*.md`) er bevisst latt stå — de er spesifikasjoner, ikke planfiler.
|
||||||
|
|
||||||
|
## 2026-05-15 — v4.11: streaming Double-Ratchet sub-sessions
|
||||||
|
Siste post-GA-økning på 4.x-linjen: `ShadeStream` gir in-memory sub-sessions (seal/open uten keystore-I/O per frame) med nye wire-typer 0x31–0x33. Låst beslutning: stream-ratchets persisteres aldri — en droppet stream gjenåpnes, aldri gjenopptas.
|
||||||
|
|
||||||
|
## 2026-05-09 — Android M-Cross 1–4 fullført + Keystore-adapter
|
||||||
|
All kryptografisk paritet TS↔Kotlin grønn via delte test-vektorer: KDF-kjede, HKDF-labels, X3DH, ratchet-steg, fingerprint, wire 0x02 og 0x11 (streams), backup-HKDF, group sender-keys og storage-HKDF. Etterslepet fra Android-roadmapen landet samme dag: scrypt + argon2id via Bouncy Castle og `shade-android-keystore`-modulen (hardware-backed master key + `KeystoreStorage`). Gjenstår: socket-interop-test og instrumenterte Keystore-tester.
|
||||||
|
|
||||||
|
## 2026-05-03 — V4.0 GA: V3.x-konsolidering og audit-pakking
|
||||||
|
Alle fasene V3.1–V3.12 ferdige og merget: dokumentasjon/hardening, at-rest storage-kryptering, trust-UX, observability (OTel), Android-paritet, inbox (store-and-forward), transport-bridge, web workers-krypto, filmetadata, social key recovery, WebRTC P2P og key transparency. Wire-formatet låst — uendret fra 0.4.x, så 4.0-peers interopererer byte-for-byte med 0.4.x. Kjernen pakket for ekstern review. Låst beslutning: alt VOIP/video skilt ut til V5.0, bygget oppå den frosne 4.0-stacken via reserverte envelope-typer (ikke breaking).
|
||||||
27
.scaffold/plan.md
Normal file
27
.scaffold/plan.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
project: "Shade"
|
||||||
|
phase: "Post-4.x GA — V5.0 (Voice & Video) i idéfase"
|
||||||
|
updated: 2026-07-09
|
||||||
|
focus: "4.x-kjernen er GA-frosset og revisjonsklar; neste løft er V5.0 sanntid oppå den låste stacken"
|
||||||
|
blockers: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan — Shade
|
||||||
|
|
||||||
|
## Mål
|
||||||
|
- E2EE-bibliotek som implementerer Signal-protokollen (X3DH + Double Ratchet) for TypeScript/Bun — drop-in for frontend, backend og mobil, med forward secrecy og post-compromise recovery.
|
||||||
|
- Byte-for-byte kryssplattform-paritet: Kotlin/Android-porten verifiseres kontinuerlig mot TS-referansen via delte test-vektorer i `test-vectors/`.
|
||||||
|
- GA-frosset, revisjonsklar 4.0-kjerne som fundament; nye kapabiliteter (sanntid i V5.0) bygges oppå uten å røre kjernekrypto-revisjonen.
|
||||||
|
|
||||||
|
## Nå
|
||||||
|
- [ ] V5.0 fra `Idea` til `Design`: designnotatet `docs/V5.0.md` (SFrame-style frame encryption, `@shade/voice` / `@shade/video` / `@shade/broadcast`) må modnes og godkjennes
|
||||||
|
- [ ] Ende-til-ende interop-test TS-server ↔ Kotlin-klient over ekte socket (anbefalt før «production»-label på Android)
|
||||||
|
- [ ] Instrumenterte tester for `KeystoreStorage` (krever Android-emulator/enhet — utsatt i v4.9/v4.10-porten)
|
||||||
|
|
||||||
|
## Neste
|
||||||
|
- V5.0-implementasjon: `@shade/voice` (1:1 voice) → `@shade/video` → `@shade/broadcast` (1:N med relay-helper)
|
||||||
|
- «Production»-label på Android når interop-testen er grønn
|
||||||
|
|
||||||
|
## Senere
|
||||||
|
- Re-pinne backup-vektoren når begge porter bytter til argon2id som passord-KDF (HKDF-placeholder flagget i threat-model; `deriveMasterKeyArgon2id` finnes allerede i Kotlin)
|
||||||
|
- Versjonsdesigndokumentene bor fortsatt i `docs/V5.0.md` og `docs/archive/V*.md` — de er spesifikasjoner, ikke planfiler
|
||||||
17
.scaffold/tasks.yaml
Normal file
17
.scaffold/tasks.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Scaffold kanban-kort. Skjema per kort:
|
||||||
|
# - id: kort-slug # unik i fila
|
||||||
|
# title: ""
|
||||||
|
# description: ""
|
||||||
|
# status: todo # todo | doing | done | blocked
|
||||||
|
# column: todo # speiler status (kanban-kolonne)
|
||||||
|
# priority: medium # low | medium | high
|
||||||
|
# assignee: human # cursor | claude | human | any
|
||||||
|
# context: "" # valgfri: hvorfor/hvor i koden
|
||||||
|
# acceptance: "" # valgfri: hva «ferdig» betyr
|
||||||
|
# todos:
|
||||||
|
# - text: "Underoppgave"
|
||||||
|
# done: false
|
||||||
|
# created: 2026-07-09
|
||||||
|
# updated: 2026-07-09
|
||||||
|
# completed_at: null
|
||||||
|
tasks: []
|
||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -5,6 +5,20 @@ All notable changes to Shade are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [4.11.2] — 2026-07-10 — Files pull-mode startup hotfix
|
||||||
|
|
||||||
|
**`@shade/files`**
|
||||||
|
|
||||||
|
- Fix a construction race in HTTP pull-mode clients: an immediate streamed
|
||||||
|
read/write now awaits asynchronous stream-bridge registration instead of
|
||||||
|
treating the not-yet-created queue drainer as proof that the client is
|
||||||
|
inline-only.
|
||||||
|
- Add a deterministic integration test that holds incoming-transfer
|
||||||
|
registration, starts a 512 KiB write immediately, and verifies that it
|
||||||
|
remains pending until streaming is ready.
|
||||||
|
- Add `publish-all.ts --only <pkg>` so isolated package hotfixes do not publish
|
||||||
|
unrelated workspace packages.
|
||||||
|
|
||||||
## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions
|
## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions
|
||||||
|
|
||||||
Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2
|
Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2
|
||||||
|
|||||||
4
CLAUDE.md
Normal file
4
CLAUDE.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
## Planlegging
|
||||||
|
Planen for dette prosjektet bor i `.scaffold/`: `plan.md` er nåbildet (hold den kort,
|
||||||
|
bump `updated` i frontmatter ved endring), `log.md` er append-only historikk (nyeste øverst),
|
||||||
|
`tasks.yaml` er kanban. Ikke opprett planfiler andre steder.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ export class ShadeSessionManager {
|
|||||||
* fully concurrent.
|
* fully concurrent.
|
||||||
*/
|
*/
|
||||||
private readonly peerOpChains = new Map<string, Promise<unknown>>();
|
private readonly peerOpChains = new Map<string, Promise<unknown>>();
|
||||||
|
/**
|
||||||
|
* Memoized `alias → canonical` lookups; `null` records "no alias" so a
|
||||||
|
* label without one costs a single storage read for the life of the
|
||||||
|
* process instead of one per encrypt/decrypt. Aliases change only via
|
||||||
|
* the mutators in this class, each of which clears the whole map — it
|
||||||
|
* holds at most one entry per peer, so rebuilding is cheap.
|
||||||
|
*/
|
||||||
|
private readonly aliasCache = new Map<string, string | null>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly crypto: CryptoProvider,
|
private readonly crypto: CryptoProvider,
|
||||||
@@ -151,6 +159,46 @@ export class ShadeSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a session label onto the label its state actually lives under.
|
||||||
|
*
|
||||||
|
* `aliasSession` moves a session from a first-contact label (typically
|
||||||
|
* `fp:<hex>`) to the peer's canonical address, but the peer keeps
|
||||||
|
* sending under the old one. The persisted alias lets us follow that
|
||||||
|
* move across restarts — see the alias block in `StorageProvider`.
|
||||||
|
*
|
||||||
|
* A live session under `label` always wins: after a re-link the peer
|
||||||
|
* re-runs X3DH and a fresh session is established under the
|
||||||
|
* first-contact label again, and that new session — not the stale
|
||||||
|
* alias target — is the one that can decrypt what follows.
|
||||||
|
*
|
||||||
|
* Resolves one hop only. Aliases are always written pointing at a
|
||||||
|
* canonical label, so a chain would mean corrupt state; following it
|
||||||
|
* would risk a loop for no legitimate gain.
|
||||||
|
*
|
||||||
|
* MUST be called before taking the peer mutex: locking the alias while
|
||||||
|
* mutating the canonical session would let an aliased caller and a
|
||||||
|
* canonical caller ratchet the same state concurrently.
|
||||||
|
*/
|
||||||
|
private async resolveLabel(label: string): Promise<string> {
|
||||||
|
let canonical = this.aliasCache.get(label);
|
||||||
|
if (canonical === undefined) {
|
||||||
|
canonical = (await this.storage.getSessionAlias?.(label)) ?? null;
|
||||||
|
this.aliasCache.set(label, canonical);
|
||||||
|
}
|
||||||
|
if (canonical === null || canonical === label) return label;
|
||||||
|
if (await this.storage.getSession(label)) return label;
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public label resolution for callers that need to know where a
|
||||||
|
* peer's state lives (e.g. a transport routing an inbound frame).
|
||||||
|
*/
|
||||||
|
async resolveSessionLabel(label: string): Promise<string> {
|
||||||
|
return this.resolveLabel(label);
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the event emitter (if observability is enabled) */
|
/** Get the event emitter (if observability is enabled) */
|
||||||
getEvents(): ShadeEventEmitter | undefined {
|
getEvents(): ShadeEventEmitter | undefined {
|
||||||
return this.events;
|
return this.events;
|
||||||
@@ -268,6 +316,11 @@ export class ShadeSessionManager {
|
|||||||
*/
|
*/
|
||||||
async resetSession(address: string): Promise<void> {
|
async resetSession(address: string): Promise<void> {
|
||||||
await this.storage.removeSession(address);
|
await this.storage.removeSession(address);
|
||||||
|
// Aliases pointing here are now dangling — drop them so the next
|
||||||
|
// first-contact frame resolves to its own label and establishes the
|
||||||
|
// fresh session this reset exists to force.
|
||||||
|
await this.storage.removeSessionAliasesFor?.(address);
|
||||||
|
this.aliasCache.clear();
|
||||||
this.events?.emit('session.removed', { address });
|
this.events?.emit('session.removed', { address });
|
||||||
// Note: we keep the trusted identity; new session will verify against it.
|
// Note: we keep the trusted identity; new session will verify against it.
|
||||||
}
|
}
|
||||||
@@ -336,6 +389,14 @@ export class ShadeSessionManager {
|
|||||||
await this.storage.bumpPeerIdentityVersion(newLabel);
|
await this.storage.bumpPeerIdentityVersion(newLabel);
|
||||||
}
|
}
|
||||||
await this.storage.removeSession(oldLabel);
|
await this.storage.removeSession(oldLabel);
|
||||||
|
// Remember the move durably. The peer goes on sending under
|
||||||
|
// `oldLabel` — its transport derives the same first-contact label
|
||||||
|
// from our relay hint every time — so without this record every
|
||||||
|
// inbound frame after a restart resolves to a label whose session
|
||||||
|
// we just removed, and the peer (holding a valid session, never
|
||||||
|
// re-running X3DH) can never recover on its own.
|
||||||
|
await this.storage.saveSessionAlias?.(oldLabel, newLabel);
|
||||||
|
this.aliasCache.clear();
|
||||||
this.events?.emit('session.aliased', { oldLabel, newLabel });
|
this.events?.emit('session.aliased', { oldLabel, newLabel });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +410,10 @@ export class ShadeSessionManager {
|
|||||||
// because isTrustedIdentity() compares not retrieves; we just emit the new hash)
|
// because isTrustedIdentity() compares not retrieves; we just emit the new hash)
|
||||||
await this.storage.saveTrustedIdentity(address, newIdentityKey);
|
await this.storage.saveTrustedIdentity(address, newIdentityKey);
|
||||||
await this.storage.removeSession(address);
|
await this.storage.removeSession(address);
|
||||||
|
// The peer rotated identity — any alias into the old session is
|
||||||
|
// dangling and must not redirect frames meant for the new one.
|
||||||
|
await this.storage.removeSessionAliasesFor?.(address);
|
||||||
|
this.aliasCache.clear();
|
||||||
|
|
||||||
if (this.events) {
|
if (this.events) {
|
||||||
const newHash = await shortHash(this.crypto, newIdentityKey);
|
const newHash = await shortHash(this.crypto, newIdentityKey);
|
||||||
@@ -516,14 +581,19 @@ export class ShadeSessionManager {
|
|||||||
* Subsequent messages are standard RatchetMessages.
|
* Subsequent messages are standard RatchetMessages.
|
||||||
*/
|
*/
|
||||||
async encrypt(address: string, plaintext: string): Promise<ShadeEnvelope> {
|
async encrypt(address: string, plaintext: string): Promise<ShadeEnvelope> {
|
||||||
return this.withSpan('encrypt', address, async () => {
|
// Follow a persisted alias so a caller still holding a first-contact
|
||||||
const session = await this.storage.getSession(address);
|
// label reaches the session that alias moved to — without this, a
|
||||||
if (!session) throw new NoSessionError(address);
|
// restarted host can decrypt a peer's frames but not reply to them.
|
||||||
|
// Resolved before the mutex so the lock lands on the canonical label.
|
||||||
|
const target = await this.resolveLabel(address);
|
||||||
|
return this.withSpan('encrypt', target, async () => {
|
||||||
|
const session = await this.storage.getSession(target);
|
||||||
|
if (!session) throw new NoSessionError(target);
|
||||||
|
|
||||||
const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext));
|
const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext));
|
||||||
|
|
||||||
this.events?.emit('message.encrypted', {
|
this.events?.emit('message.encrypted', {
|
||||||
address,
|
address: target,
|
||||||
counter: ratchetMsg.counter,
|
counter: ratchetMsg.counter,
|
||||||
ciphertextSize: ratchetMsg.ciphertext.length,
|
ciphertextSize: ratchetMsg.ciphertext.length,
|
||||||
});
|
});
|
||||||
@@ -532,7 +602,7 @@ export class ShadeSessionManager {
|
|||||||
const x3dh = (session as any).__x3dh;
|
const x3dh = (session as any).__x3dh;
|
||||||
if (x3dh) {
|
if (x3dh) {
|
||||||
delete (session as any).__x3dh;
|
delete (session as any).__x3dh;
|
||||||
await this.storage.saveSession(address, session);
|
await this.storage.saveSession(target, session);
|
||||||
|
|
||||||
const preKeyMsg: PreKeyMessage = {
|
const preKeyMsg: PreKeyMessage = {
|
||||||
registrationId: x3dh.registrationId,
|
registrationId: x3dh.registrationId,
|
||||||
@@ -546,16 +616,16 @@ export class ShadeSessionManager {
|
|||||||
type: 'prekey',
|
type: 'prekey',
|
||||||
content: preKeyMsg,
|
content: preKeyMsg,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
senderAddress: address,
|
senderAddress: target,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storage.saveSession(address, session);
|
await this.storage.saveSession(target, session);
|
||||||
return {
|
return {
|
||||||
type: 'ratchet',
|
type: 'ratchet',
|
||||||
content: ratchetMsg,
|
content: ratchetMsg,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
senderAddress: address,
|
senderAddress: target,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -564,11 +634,17 @@ export class ShadeSessionManager {
|
|||||||
* Decrypt a message from a peer. Handles both PreKeyMessage and RatchetMessage.
|
* Decrypt a message from a peer. Handles both PreKeyMessage and RatchetMessage.
|
||||||
*/
|
*/
|
||||||
async decrypt(address: string, envelope: ShadeEnvelope): Promise<string> {
|
async decrypt(address: string, envelope: ShadeEnvelope): Promise<string> {
|
||||||
return this.withSpan('decrypt', address, async () => {
|
// A prekey envelope carries its own X3DH material and establishes a
|
||||||
|
// fresh session, which must land under the label it arrived on —
|
||||||
|
// that is exactly what a re-link looks like. Only ratchet envelopes,
|
||||||
|
// which need state that already exists, follow an alias.
|
||||||
|
const target =
|
||||||
|
envelope.type === 'prekey' ? address : await this.resolveLabel(address);
|
||||||
|
return this.withSpan('decrypt', target, async () => {
|
||||||
if (envelope.type === 'prekey') {
|
if (envelope.type === 'prekey') {
|
||||||
return this.decryptPreKeyMessage(address, envelope.content as PreKeyMessage);
|
return this.decryptPreKeyMessage(target, envelope.content as PreKeyMessage);
|
||||||
}
|
}
|
||||||
return this.decryptRatchetMessage(address, envelope.content as RatchetMessage);
|
return this.decryptRatchetMessage(target, envelope.content as RatchetMessage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,36 @@ export interface StorageProvider {
|
|||||||
/** Remove session for a peer */
|
/** Remove session for a peer */
|
||||||
removeSession(address: string): Promise<void>;
|
removeSession(address: string): Promise<void>;
|
||||||
|
|
||||||
|
// ─── Session label aliases (V4.12) ────────────────────────
|
||||||
|
//
|
||||||
|
// First contact forces the receiver to label a session by the only
|
||||||
|
// sender hint the relay surfaces — an 8-byte signing-key fingerprint
|
||||||
|
// (`fp:<hex>`). A later in-band announcement reveals the peer's
|
||||||
|
// canonical address and `aliasSession` moves the session there.
|
||||||
|
//
|
||||||
|
// The peer, however, keeps sending under whatever label its own
|
||||||
|
// transport derives — which for a fingerprint-hinted relay is still
|
||||||
|
// `fp:<hex>`. Before V4.12 that binding lived only in the consumer's
|
||||||
|
// memory: after a restart the alias was gone, inbound ratchet frames
|
||||||
|
// resolved to `fp:<hex>`, found no session there, and failed forever
|
||||||
|
// (the peer holds a valid session so it never re-runs X3DH).
|
||||||
|
//
|
||||||
|
// Persisting the alias makes the binding survive restarts, so
|
||||||
|
// `getSession` can follow it. Optional so third-party storage
|
||||||
|
// implementations keep compiling — they simply lose alias recovery.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that `alias` names the same peer session as `canonical`.
|
||||||
|
* Idempotent upsert on `alias`.
|
||||||
|
*/
|
||||||
|
saveSessionAlias?(alias: string, canonical: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Resolve an alias to its canonical label (null when unaliased). */
|
||||||
|
getSessionAlias?(alias: string): Promise<string | null>;
|
||||||
|
|
||||||
|
/** Drop every alias pointing at `canonical` (session teardown). */
|
||||||
|
removeSessionAliasesFor?(canonical: string): Promise<void>;
|
||||||
|
|
||||||
/** Check if we trust a remote identity key (for TOFU or pinned keys) */
|
/** Check if we trust a remote identity key (for TOFU or pinned keys) */
|
||||||
isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>;
|
isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean>;
|
||||||
|
|
||||||
|
|||||||
165
packages/shade-core/tests/session-aliases.test.ts
Normal file
165
packages/shade-core/tests/session-aliases.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||||
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||||
|
import { ShadeSessionManager } from '../src/index.js';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable session-label aliases (V4.12).
|
||||||
|
*
|
||||||
|
* THE BUG THIS FILE EXISTS TO KILL — diagnosed live in Prism:
|
||||||
|
*
|
||||||
|
* A phone pairs with a host. First contact forces the host to label
|
||||||
|
* the session by the only sender hint the relay surfaces, an 8-byte
|
||||||
|
* signing-key fingerprint (`fp:<hex>`). The pair handshake then
|
||||||
|
* announces the phone's canonical address and the host calls
|
||||||
|
* `aliasSession(fp:<hex> → device:<addr>)`, which moved the session
|
||||||
|
* on disk and dropped the binding.
|
||||||
|
*
|
||||||
|
* The phone, however, keeps sending under `fp:<hex>` — its transport
|
||||||
|
* derives the same label from the same relay hint every time. While
|
||||||
|
* the host process lived, an in-memory map papered over the gap.
|
||||||
|
* After a restart that map was empty, every inbound ratchet frame
|
||||||
|
* resolved to `fp:<hex>`, found no session, and failed. The phone
|
||||||
|
* held a perfectly valid session so it never re-ran X3DH — meaning
|
||||||
|
* the failure was permanent and self-inflicted, not transient.
|
||||||
|
*
|
||||||
|
* Observed as `No session for address: fp:579c3b335d66e2c0` on every
|
||||||
|
* receive for three days, with the phone's RPCs timing out forever.
|
||||||
|
*
|
||||||
|
* The fix: `aliasSession` persists the binding, and session lookup
|
||||||
|
* follows it. These tests pin the restart behaviour specifically —
|
||||||
|
* a same-process test cannot fail the way production did.
|
||||||
|
*/
|
||||||
|
describe('session label aliases', () => {
|
||||||
|
let alice: ShadeSessionManager;
|
||||||
|
let bob: ShadeSessionManager;
|
||||||
|
let aliceStorage: MemoryStorage;
|
||||||
|
let bobStorage: MemoryStorage;
|
||||||
|
|
||||||
|
/** The first-contact label Alice is forced to use for Bob. */
|
||||||
|
const FP = 'fp:579c3b335d66e2c0';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
aliceStorage = new MemoryStorage();
|
||||||
|
bobStorage = new MemoryStorage();
|
||||||
|
alice = new ShadeSessionManager(crypto, aliceStorage);
|
||||||
|
bob = new ShadeSessionManager(crypto, bobStorage);
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bob initiates X3DH against Alice, exactly like a phone reaching a
|
||||||
|
* host it just scanned. Returns Bob's first (prekey) envelope.
|
||||||
|
*/
|
||||||
|
async function bobInitiates(target: ShadeSessionManager, initiator: ShadeSessionManager) {
|
||||||
|
const otpks = await target.generateOneTimePreKeys(10);
|
||||||
|
const bundle = await target.createPreKeyBundle();
|
||||||
|
const otpk = otpks[0]!;
|
||||||
|
bundle.oneTimePreKey = { keyId: otpk.keyId, publicKey: otpk.keyPair.publicKey };
|
||||||
|
await initiator.initSessionFromBundle('alice', bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate a host restart: fresh manager, same durable storage. */
|
||||||
|
async function restartAlice(): Promise<ShadeSessionManager> {
|
||||||
|
const revived = new ShadeSessionManager(crypto, aliceStorage);
|
||||||
|
await revived.initialize();
|
||||||
|
return revived;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an aliased session still decrypts under the old label after a restart', async () => {
|
||||||
|
await bobInitiates(alice, bob);
|
||||||
|
|
||||||
|
// First contact lands under the fingerprint label.
|
||||||
|
const env1 = await bob.encrypt('alice', 'hello, my address is bob');
|
||||||
|
expect(await alice.decrypt(FP, env1)).toBe('hello, my address is bob');
|
||||||
|
|
||||||
|
// Alice canonicalizes to Bob's announced address.
|
||||||
|
await alice.aliasSession(FP, 'bob');
|
||||||
|
|
||||||
|
// The host restarts. Storage survives; every in-memory map does not.
|
||||||
|
const alice2 = await restartAlice();
|
||||||
|
|
||||||
|
// Bob has a valid session and keeps sending under the same label he
|
||||||
|
// always has. Before the fix this threw NoSessionError forever.
|
||||||
|
const env2 = await bob.encrypt('alice', 'still here after restart');
|
||||||
|
expect(await alice2.decrypt(FP, env2)).toBe('still here after restart');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the host can reply under the old label after a restart', async () => {
|
||||||
|
await bobInitiates(alice, bob);
|
||||||
|
const env1 = await bob.encrypt('alice', 'hi');
|
||||||
|
await alice.decrypt(FP, env1);
|
||||||
|
await alice.aliasSession(FP, 'bob');
|
||||||
|
|
||||||
|
const alice2 = await restartAlice();
|
||||||
|
|
||||||
|
// Decrypting is only half of it — a host that cannot encrypt back
|
||||||
|
// leaves every RPC hanging just the same.
|
||||||
|
const reply = await alice2.encrypt(FP, 'reply from the host');
|
||||||
|
expect(await bob.decrypt('alice', reply)).toBe('reply from the host');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a live session under the label wins over an alias (re-link)', async () => {
|
||||||
|
await bobInitiates(alice, bob);
|
||||||
|
const env1 = await bob.encrypt('alice', 'first pairing');
|
||||||
|
await alice.decrypt(FP, env1);
|
||||||
|
await alice.aliasSession(FP, 'bob');
|
||||||
|
const alice2 = await restartAlice();
|
||||||
|
|
||||||
|
// Bob reinstalls: brand-new identity, same relay fingerprint label.
|
||||||
|
const bob2Storage = new MemoryStorage();
|
||||||
|
const bob2 = new ShadeSessionManager(crypto, bob2Storage);
|
||||||
|
await bob2.initialize();
|
||||||
|
await bobInitiates(alice2, bob2);
|
||||||
|
|
||||||
|
// The prekey envelope must establish a FRESH session under FP rather
|
||||||
|
// than being redirected into the stale aliased one.
|
||||||
|
const fresh1 = await bob2.encrypt('alice', 'fresh contact');
|
||||||
|
expect(await alice2.decrypt(FP, fresh1)).toBe('fresh contact');
|
||||||
|
|
||||||
|
// And subsequent ratchet frames must keep using that new session.
|
||||||
|
const fresh2 = await bob2.encrypt('alice', 'second message');
|
||||||
|
expect(await alice2.decrypt(FP, fresh2)).toBe('second message');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSessionLabel reports where the state actually lives', async () => {
|
||||||
|
await bobInitiates(alice, bob);
|
||||||
|
const env1 = await bob.encrypt('alice', 'hi');
|
||||||
|
await alice.decrypt(FP, env1);
|
||||||
|
|
||||||
|
expect(await alice.resolveSessionLabel(FP)).toBe(FP);
|
||||||
|
await alice.aliasSession(FP, 'bob');
|
||||||
|
|
||||||
|
const alice2 = await restartAlice();
|
||||||
|
expect(await alice2.resolveSessionLabel(FP)).toBe('bob');
|
||||||
|
// An unaliased label resolves to itself.
|
||||||
|
expect(await alice2.resolveSessionLabel('carol')).toBe('carol');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetSession drops aliases pointing at the cleared session', async () => {
|
||||||
|
await bobInitiates(alice, bob);
|
||||||
|
const env1 = await bob.encrypt('alice', 'hi');
|
||||||
|
await alice.decrypt(FP, env1);
|
||||||
|
await alice.aliasSession(FP, 'bob');
|
||||||
|
expect(await aliceStorage.getSessionAlias(FP)).toBe('bob');
|
||||||
|
|
||||||
|
await alice.resetSession('bob');
|
||||||
|
|
||||||
|
// A dangling alias would redirect the next first-contact frame into
|
||||||
|
// a session that no longer exists, defeating the reset.
|
||||||
|
expect(await aliceStorage.getSessionAlias(FP)).toBeNull();
|
||||||
|
expect(await alice.resolveSessionLabel(FP)).toBe(FP);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliasing persists the binding to storage', async () => {
|
||||||
|
await bobInitiates(alice, bob);
|
||||||
|
const env1 = await bob.encrypt('alice', 'hi');
|
||||||
|
await alice.decrypt(FP, env1);
|
||||||
|
|
||||||
|
expect(await aliceStorage.getSessionAlias(FP)).toBeNull();
|
||||||
|
await alice.aliasSession(FP, 'bob');
|
||||||
|
expect(await aliceStorage.getSessionAlias(FP)).toBe('bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export class MemoryStorage implements StorageProvider {
|
|||||||
private signedPreKeys = new Map<number, SignedPreKey>();
|
private signedPreKeys = new Map<number, SignedPreKey>();
|
||||||
private oneTimePreKeys = new Map<number, OneTimePreKey>();
|
private oneTimePreKeys = new Map<number, OneTimePreKey>();
|
||||||
private sessions = new Map<string, SessionState>();
|
private sessions = new Map<string, SessionState>();
|
||||||
|
/** alias → canonical session label (V4.12). */
|
||||||
|
private sessionAliases = new Map<string, string>();
|
||||||
private trustedIdentities = new Map<string, Uint8Array>();
|
private trustedIdentities = new Map<string, Uint8Array>();
|
||||||
private retiredIdentities: RetiredIdentity[] = [];
|
private retiredIdentities: RetiredIdentity[] = [];
|
||||||
|
|
||||||
@@ -82,6 +84,22 @@ export class MemoryStorage implements StorageProvider {
|
|||||||
this.sessions.delete(address);
|
this.sessions.delete(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ────────────────────────────────
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
return this.sessionAliases.get(alias) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
this.sessionAliases.set(alias, canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
for (const [alias, target] of this.sessionAliases) {
|
||||||
|
if (target === canonical) this.sessionAliases.delete(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ────────────────────────────────────────────────
|
// ─── Trust ────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -444,8 +444,12 @@ export function createFilesHttpClient(
|
|||||||
};
|
};
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
// Streamed read — only supported when the queue drainer is wired.
|
// Streamed read — only supported when pull-mode was configured.
|
||||||
if (drainer === null) {
|
// `drainer` is assigned asynchronously after the streams bridge has
|
||||||
|
// subscribed, so checking it here races client construction. The
|
||||||
|
// promise itself is the synchronous configuration signal; awaiting it
|
||||||
|
// also guarantees the drainer has been installed by its `.then()`.
|
||||||
|
if (streamsBridgePromise === null) {
|
||||||
throw new InternalFileError(
|
throw new InternalFileError(
|
||||||
`http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`,
|
`http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`,
|
||||||
);
|
);
|
||||||
@@ -507,8 +511,11 @@ export function createFilesHttpClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streamed write — requires the queue drainer + streams-bridge.
|
// Streamed write — requires pull-mode configuration. Do not inspect
|
||||||
if (drainer === null) {
|
// `drainer` as a readiness flag: bridge registration is asynchronous,
|
||||||
|
// and an immediate large write must wait for it instead of being
|
||||||
|
// misclassified as an inline-only client.
|
||||||
|
if (streamsBridgePromise === null) {
|
||||||
throw new ConflictError(
|
throw new ConflictError(
|
||||||
`http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`,
|
`http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
} from '@shade/server';
|
} from '@shade/server';
|
||||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { createFilesHttpClient, type FileEntry } from '../../src/index.js';
|
||||||
|
|
||||||
const crypto = new SubtleCryptoProvider();
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
type ShadeInstance = Awaited<ReturnType<typeof createShade>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stand up the full pull-mode rig:
|
* Stand up the full pull-mode rig:
|
||||||
@@ -20,7 +22,8 @@ const crypto = new SubtleCryptoProvider();
|
|||||||
* no inbound listener, streams supported via long-poll.
|
* no inbound listener, streams supported via long-poll.
|
||||||
*/
|
*/
|
||||||
async function setupPullRig(opts: {
|
async function setupPullRig(opts: {
|
||||||
bobHandler: Parameters<NonNullable<Awaited<ReturnType<typeof createShade>>['files']>['serve']>[0];
|
bobHandler: Parameters<NonNullable<ShadeInstance['files']>['serve']>[0];
|
||||||
|
wrapClientShade?: (shade: ShadeInstance) => Parameters<typeof createFilesHttpClient>[0];
|
||||||
}) {
|
}) {
|
||||||
const prekey = createPrekeyServer({
|
const prekey = createPrekeyServer({
|
||||||
crypto,
|
crypto,
|
||||||
@@ -46,7 +49,7 @@ async function setupPullRig(opts: {
|
|||||||
const bobServer = Bun.serve({ port: 0, fetch: app.fetch });
|
const bobServer = Bun.serve({ port: 0, fetch: app.fetch });
|
||||||
const baseUrl = `http://localhost:${bobServer.port}`;
|
const baseUrl = `http://localhost:${bobServer.port}`;
|
||||||
|
|
||||||
const fs = alice.files.httpClient('bob', {
|
const fs = createFilesHttpClient(opts.wrapClientShade?.(alice) ?? alice, 'bob', {
|
||||||
rpcUrl: `${baseUrl}/rpc`,
|
rpcUrl: `${baseUrl}/rpc`,
|
||||||
outboundQueueUrl: `${baseUrl}/queue`,
|
outboundQueueUrl: `${baseUrl}/queue`,
|
||||||
transferBaseUrl: baseUrl,
|
transferBaseUrl: baseUrl,
|
||||||
@@ -70,6 +73,83 @@ async function setupPullRig(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('@shade/files HTTP RPC — pull-mode streams', () => {
|
describe('@shade/files HTTP RPC — pull-mode streams', () => {
|
||||||
|
test('immediate streamed write waits for asynchronous stream-bridge registration', async () => {
|
||||||
|
const payload = new Uint8Array(512 * 1024);
|
||||||
|
for (let i = 0; i < payload.length; i++) payload[i] = (i * 53) & 0xff;
|
||||||
|
|
||||||
|
let releaseRegistration!: () => void;
|
||||||
|
const registrationGate = new Promise<void>((resolve) => {
|
||||||
|
releaseRegistration = resolve;
|
||||||
|
});
|
||||||
|
let registrationStarted!: () => void;
|
||||||
|
const started = new Promise<void>((resolve) => {
|
||||||
|
registrationStarted = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rig = await setupPullRig({
|
||||||
|
wrapClientShade: (alice) =>
|
||||||
|
new Proxy(alice, {
|
||||||
|
get(target, property) {
|
||||||
|
if (property === 'onIncomingTransfer') {
|
||||||
|
return async (handler: Parameters<typeof target.onIncomingTransfer>[0]) => {
|
||||||
|
registrationStarted();
|
||||||
|
await registrationGate;
|
||||||
|
return await target.onIncomingTransfer(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, property, target) as unknown;
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
bobHandler: {
|
||||||
|
write: async (ctx) => {
|
||||||
|
const content = ctx.args.content;
|
||||||
|
if (content.kind !== 'streams') throw new Error('expected streamed content');
|
||||||
|
const reader = content.stream.getReader();
|
||||||
|
let received = 0;
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
received += value?.byteLength ?? 0;
|
||||||
|
}
|
||||||
|
reader.releaseLock();
|
||||||
|
await content.sha256;
|
||||||
|
const entry: FileEntry = {
|
||||||
|
name: 'immediate.bin',
|
||||||
|
kind: 'file',
|
||||||
|
size: received,
|
||||||
|
mtime: Date.now(),
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
return { entry };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let settled = false;
|
||||||
|
const write = rig.fs.write('/immediate.bin', payload);
|
||||||
|
void write.then(
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await started;
|
||||||
|
await Bun.sleep(10);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
releaseRegistration();
|
||||||
|
const result = await write;
|
||||||
|
expect(result.entry.size).toBe(payload.byteLength);
|
||||||
|
} finally {
|
||||||
|
releaseRegistration();
|
||||||
|
await rig.teardown();
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
test('streamed read (4 MiB) via long-poll queue', async () => {
|
test('streamed read (4 MiB) via long-poll queue', async () => {
|
||||||
const payload = new Uint8Array(4 * 1024 * 1024);
|
const payload = new Uint8Array(4 * 1024 * 1024);
|
||||||
for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff;
|
for (let i = 0; i < payload.length; i++) payload[i] = (i * 97) & 0xff;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -701,6 +701,22 @@ export class Shade {
|
|||||||
this.decryptChains.delete(oldLabel);
|
this.decryptChains.delete(oldLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a session label to the label its state actually lives under,
|
||||||
|
* following any alias left behind by `aliasSession`.
|
||||||
|
*
|
||||||
|
* Transports need this to route by the canonical address after a
|
||||||
|
* restart, when the only sender hint they hold is the first-contact
|
||||||
|
* `fp:<hex>` label. `encrypt`/`decrypt` resolve internally — this is
|
||||||
|
* for callers that must know the address itself.
|
||||||
|
*
|
||||||
|
* V4.12 — durable session-label aliases.
|
||||||
|
*/
|
||||||
|
async resolveSessionLabel(label: string): Promise<string> {
|
||||||
|
if (!this.initialized) throw new Error('Not initialized');
|
||||||
|
return this.manager.resolveSessionLabel(label);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a peer's rotated identity. Bumps the per-peer identity-version
|
* Accept a peer's rotated identity. Bumps the per-peer identity-version
|
||||||
* counter so any earlier verification automatically goes stale, then
|
* counter so any earlier verification automatically goes stale, then
|
||||||
|
|||||||
@@ -162,10 +162,15 @@ describe('createShade — happy path', () => {
|
|||||||
const env3 = await alice.send('bob', 'reply 2');
|
const env3 = await alice.send('bob', 'reply 2');
|
||||||
expect(await bob.receive('alice', env3)).toBe('reply 2');
|
expect(await bob.receive('alice', env3)).toBe('reply 2');
|
||||||
|
|
||||||
// The old fp-label has no session — receive under it would now
|
// V4.12: the fp-label is no longer a dead end. `aliasSession` leaves
|
||||||
// fail. (We don't assert the error shape, only that the label is
|
// a durable binding behind, so a peer that keeps sending under the
|
||||||
// gone.)
|
// first-contact label — which is exactly what a fingerprint-hinted
|
||||||
await expect(alice.receive(fpLabel, env3)).rejects.toThrow();
|
// relay makes it do — still reaches the canonicalized session.
|
||||||
|
// See `shade-core/tests/session-aliases.test.ts` for the restart
|
||||||
|
// behaviour this binding exists to protect.
|
||||||
|
expect(await alice.resolveSessionLabel(fpLabel)).toBe('bob');
|
||||||
|
const env4 = await bob.send('alice', 'reply 3');
|
||||||
|
expect(await alice.receive(fpLabel, env4)).toBe('reply 3');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('aliasSession refuses to overwrite an existing session', async () => {
|
test('aliasSession refuses to overwrite an existing session', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
|
|||||||
});
|
});
|
||||||
members.createIndex('byChannelId', 'channelId');
|
members.createIndex('byChannelId', 'channelId');
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
const aliases = db.createObjectStore('session_aliases_enc', { keyPath: 'alias' });
|
||||||
|
aliases.createIndex('byCanonical', 'canonical');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
|
const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
|
||||||
@@ -212,6 +216,27 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
|
|||||||
await this.db.delete('sessions_enc', address);
|
await this.db.delete('sessions_enc', address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// Labels are already the clear-text keyPath of sessions_enc, so the
|
||||||
|
// mapping between two of them exposes nothing the store didn't hold.
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
const row = await this.db.get('session_aliases_enc', alias);
|
||||||
|
return row?.canonical ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
await this.db.put('session_aliases_enc', { alias, canonical });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
const tx = this.db.transaction('session_aliases_enc', 'readwrite');
|
||||||
|
const matches = await tx.store.index('byCanonical').getAllKeys(canonical);
|
||||||
|
await Promise.all(matches.map((key) => tx.store.delete(key)));
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ─────────────────────────────────────────────────
|
// ─── Trust ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
@@ -466,7 +491,7 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
|
|||||||
|
|
||||||
// ─── Schema ────────────────────────────────────────────────
|
// ─── Schema ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 2;
|
const SCHEMA_VERSION = 3;
|
||||||
|
|
||||||
interface MetaRow { key: string; value: string }
|
interface MetaRow { key: string; value: string }
|
||||||
interface IdentityRow { id: 1; ciphertext: Uint8Array }
|
interface IdentityRow { id: 1; ciphertext: Uint8Array }
|
||||||
@@ -522,6 +547,11 @@ interface EncryptedShadeSchema extends DBSchema {
|
|||||||
signed_prekeys_enc: { key: number; value: SignedPreKeyRow };
|
signed_prekeys_enc: { key: number; value: SignedPreKeyRow };
|
||||||
one_time_prekeys_enc: { key: number; value: OneTimePreKeyRow };
|
one_time_prekeys_enc: { key: number; value: OneTimePreKeyRow };
|
||||||
sessions_enc: { key: string; value: SessionRow };
|
sessions_enc: { key: string; value: SessionRow };
|
||||||
|
session_aliases_enc: {
|
||||||
|
key: string;
|
||||||
|
value: { alias: string; canonical: string };
|
||||||
|
indexes: { byCanonical: string };
|
||||||
|
};
|
||||||
trusted_identities_enc: { key: string; value: TrustedIdentityRow };
|
trusted_identities_enc: { key: string; value: TrustedIdentityRow };
|
||||||
retired_identities_enc: {
|
retired_identities_enc: {
|
||||||
key: number;
|
key: number;
|
||||||
|
|||||||
@@ -183,6 +183,30 @@ export class EncryptedPostgresStorage implements StorageProvider {
|
|||||||
await this.sql`DELETE FROM shade_sessions_enc WHERE address = ${address}`;
|
await this.sql`DELETE FROM shade_sessions_enc WHERE address = ${address}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// Labels are already stored in the clear as the sessions_enc primary
|
||||||
|
// key, so a mapping between two of them reveals nothing new.
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
const rows = await this.sql<Array<{ canonical: string }>>`
|
||||||
|
SELECT canonical FROM shade_session_aliases_enc WHERE alias = ${alias}
|
||||||
|
`;
|
||||||
|
return rows.length ? rows[0]!.canonical : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
INSERT INTO shade_session_aliases_enc (alias, canonical)
|
||||||
|
VALUES (${alias}, ${canonical})
|
||||||
|
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
await this.sql`DELETE FROM shade_session_aliases_enc WHERE canonical = ${canonical}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ─────────────────────────────────────────────────
|
// ─── Trust ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
@@ -515,6 +539,16 @@ export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
|
|||||||
ciphertext BYTEA NOT NULL
|
ciphertext BYTEA NOT NULL
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_session_aliases_enc (
|
||||||
|
alias TEXT PRIMARY KEY,
|
||||||
|
canonical TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shade_session_aliases_enc_canonical
|
||||||
|
ON shade_session_aliases_enc(canonical)
|
||||||
|
`;
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS shade_trusted_identities_enc (
|
CREATE TABLE IF NOT EXISTS shade_trusted_identities_enc (
|
||||||
address TEXT PRIMARY KEY,
|
address TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
getSession: ReturnType<Database['prepare']>;
|
getSession: ReturnType<Database['prepare']>;
|
||||||
saveSession: ReturnType<Database['prepare']>;
|
saveSession: ReturnType<Database['prepare']>;
|
||||||
removeSession: ReturnType<Database['prepare']>;
|
removeSession: ReturnType<Database['prepare']>;
|
||||||
|
getSessionAlias: ReturnType<Database['prepare']>;
|
||||||
|
saveSessionAlias: ReturnType<Database['prepare']>;
|
||||||
|
removeAliasesFor: ReturnType<Database['prepare']>;
|
||||||
getTrust: ReturnType<Database['prepare']>;
|
getTrust: ReturnType<Database['prepare']>;
|
||||||
saveTrust: ReturnType<Database['prepare']>;
|
saveTrust: ReturnType<Database['prepare']>;
|
||||||
addRetired: ReturnType<Database['prepare']>;
|
addRetired: ReturnType<Database['prepare']>;
|
||||||
@@ -134,6 +137,15 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
address TEXT PRIMARY KEY,
|
address TEXT PRIMARY KEY,
|
||||||
ciphertext BLOB NOT NULL
|
ciphertext BLOB NOT NULL
|
||||||
);
|
);
|
||||||
|
-- Session-label aliases (V4.12). Labels are already stored in the
|
||||||
|
-- clear as the sessions_enc primary key, so the mapping between two
|
||||||
|
-- of them reveals nothing new; only session state is encrypted.
|
||||||
|
CREATE TABLE IF NOT EXISTS session_aliases_enc (
|
||||||
|
alias TEXT PRIMARY KEY,
|
||||||
|
canonical TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_aliases_enc_canonical
|
||||||
|
ON session_aliases_enc(canonical);
|
||||||
CREATE TABLE IF NOT EXISTS trusted_identities_enc (
|
CREATE TABLE IF NOT EXISTS trusted_identities_enc (
|
||||||
address TEXT PRIMARY KEY,
|
address TEXT PRIMARY KEY,
|
||||||
ciphertext BLOB NOT NULL
|
ciphertext BLOB NOT NULL
|
||||||
@@ -203,6 +215,9 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'),
|
getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'),
|
||||||
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'),
|
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'),
|
||||||
removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'),
|
removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'),
|
||||||
|
getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases_enc WHERE alias = ?'),
|
||||||
|
saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases_enc (alias, canonical) VALUES (?, ?)'),
|
||||||
|
removeAliasesFor: this.db.prepare('DELETE FROM session_aliases_enc WHERE canonical = ?'),
|
||||||
getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'),
|
getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'),
|
||||||
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'),
|
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'),
|
||||||
addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'),
|
addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'),
|
||||||
@@ -377,6 +392,21 @@ export class EncryptedSQLiteStorage implements StorageProvider {
|
|||||||
this.stmts.removeSession.run(address);
|
this.stmts.removeSession.run(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ─────────────────────────────────
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined;
|
||||||
|
return row?.canonical ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
this.stmts.saveSessionAlias.run(alias, canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
this.stmts.removeAliasesFor.run(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ─────────────────────────────────────────────────
|
// ─── Trust ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export class IndexedDBStorage implements StorageProvider {
|
|||||||
const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] });
|
const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] });
|
||||||
members.createIndex('byChannelId', 'channelId');
|
members.createIndex('byChannelId', 'channelId');
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
const aliases = db.createObjectStore('sessionAliases', { keyPath: 'alias' });
|
||||||
|
aliases.createIndex('byCanonical', 'canonical');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return new IndexedDBStorage(db);
|
return new IndexedDBStorage(db);
|
||||||
@@ -174,6 +178,24 @@ export class IndexedDBStorage implements StorageProvider {
|
|||||||
await this.db.delete('sessions', address);
|
await this.db.delete('sessions', address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ────────────────────────────────
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
const row = await this.db.get('sessionAliases', alias);
|
||||||
|
return row?.canonical ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
await this.db.put('sessionAliases', { alias, canonical });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
const tx = this.db.transaction('sessionAliases', 'readwrite');
|
||||||
|
const matches = await tx.store.index('byCanonical').getAllKeys(canonical);
|
||||||
|
await Promise.all(matches.map((key) => tx.store.delete(key)));
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ────────────────────────────────────────────────
|
// ─── Trust ────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
@@ -360,7 +382,7 @@ export class IndexedDBStorage implements StorageProvider {
|
|||||||
|
|
||||||
// ─── Schema ────────────────────────────────────────────────
|
// ─── Schema ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 2;
|
const SCHEMA_VERSION = 3;
|
||||||
|
|
||||||
interface IdentityRow {
|
interface IdentityRow {
|
||||||
id: 1;
|
id: 1;
|
||||||
@@ -390,6 +412,11 @@ interface SessionRow {
|
|||||||
stateJson: string;
|
stateJson: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionAliasRow {
|
||||||
|
alias: string;
|
||||||
|
canonical: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TrustedIdentityRow {
|
interface TrustedIdentityRow {
|
||||||
address: string;
|
address: string;
|
||||||
identityKey: string;
|
identityKey: string;
|
||||||
@@ -457,6 +484,11 @@ interface ShadeSchema extends DBSchema {
|
|||||||
signedPreKeys: { key: number; value: SignedPreKeyRow };
|
signedPreKeys: { key: number; value: SignedPreKeyRow };
|
||||||
oneTimePreKeys: { key: number; value: OneTimePreKeyRow };
|
oneTimePreKeys: { key: number; value: OneTimePreKeyRow };
|
||||||
sessions: { key: string; value: SessionRow };
|
sessions: { key: string; value: SessionRow };
|
||||||
|
sessionAliases: {
|
||||||
|
key: string;
|
||||||
|
value: SessionAliasRow;
|
||||||
|
indexes: { byCanonical: string };
|
||||||
|
};
|
||||||
trustedIdentities: { key: string; value: TrustedIdentityRow };
|
trustedIdentities: { key: string; value: TrustedIdentityRow };
|
||||||
retiredIdentities: {
|
retiredIdentities: {
|
||||||
key: number;
|
key: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ export async function ensureClientTables(sql: Sql): Promise<void> {
|
|||||||
state_json TEXT NOT NULL
|
state_json TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_session_aliases (
|
||||||
|
alias TEXT PRIMARY KEY,
|
||||||
|
canonical TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shade_session_aliases_canonical
|
||||||
|
ON shade_session_aliases(canonical)
|
||||||
|
`;
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS shade_trusted_identities (
|
CREATE TABLE IF NOT EXISTS shade_trusted_identities (
|
||||||
address TEXT PRIMARY KEY,
|
address TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -160,6 +160,27 @@ export class PostgresStorage implements StorageProvider {
|
|||||||
await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`;
|
await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ────────────────────────────────
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
const rows = await this.sql<Array<{ canonical: string }>>`
|
||||||
|
SELECT canonical FROM shade_session_aliases WHERE alias = ${alias}
|
||||||
|
`;
|
||||||
|
return rows.length ? rows[0]!.canonical : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
await this.sql`
|
||||||
|
INSERT INTO shade_session_aliases (alias, canonical)
|
||||||
|
VALUES (${alias}, ${canonical})
|
||||||
|
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
await this.sql`DELETE FROM shade_session_aliases WHERE canonical = ${canonical}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ────────────────────────────────────────────────
|
// ─── Trust ────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
getSession: ReturnType<Database['prepare']>;
|
getSession: ReturnType<Database['prepare']>;
|
||||||
saveSession: ReturnType<Database['prepare']>;
|
saveSession: ReturnType<Database['prepare']>;
|
||||||
removeSession: ReturnType<Database['prepare']>;
|
removeSession: ReturnType<Database['prepare']>;
|
||||||
|
getSessionAlias: ReturnType<Database['prepare']>;
|
||||||
|
saveSessionAlias: ReturnType<Database['prepare']>;
|
||||||
|
removeAliasesFor: ReturnType<Database['prepare']>;
|
||||||
getTrust: ReturnType<Database['prepare']>;
|
getTrust: ReturnType<Database['prepare']>;
|
||||||
saveTrust: ReturnType<Database['prepare']>;
|
saveTrust: ReturnType<Database['prepare']>;
|
||||||
addRetired: ReturnType<Database['prepare']>;
|
addRetired: ReturnType<Database['prepare']>;
|
||||||
@@ -100,6 +103,12 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
address TEXT PRIMARY KEY,
|
address TEXT PRIMARY KEY,
|
||||||
state_json TEXT NOT NULL
|
state_json TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS session_aliases (
|
||||||
|
alias TEXT PRIMARY KEY,
|
||||||
|
canonical TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_aliases_canonical
|
||||||
|
ON session_aliases(canonical);
|
||||||
CREATE TABLE IF NOT EXISTS trusted_identities (
|
CREATE TABLE IF NOT EXISTS trusted_identities (
|
||||||
address TEXT PRIMARY KEY,
|
address TEXT PRIMARY KEY,
|
||||||
identity_key TEXT NOT NULL
|
identity_key TEXT NOT NULL
|
||||||
@@ -179,6 +188,9 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
getSession: this.db.prepare('SELECT state_json FROM sessions WHERE address = ?'),
|
getSession: this.db.prepare('SELECT state_json FROM sessions WHERE address = ?'),
|
||||||
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions (address, state_json) VALUES (?, ?)'),
|
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions (address, state_json) VALUES (?, ?)'),
|
||||||
removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'),
|
removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'),
|
||||||
|
getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases WHERE alias = ?'),
|
||||||
|
saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases (alias, canonical) VALUES (?, ?)'),
|
||||||
|
removeAliasesFor: this.db.prepare('DELETE FROM session_aliases WHERE canonical = ?'),
|
||||||
getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'),
|
getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'),
|
||||||
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'),
|
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'),
|
||||||
addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'),
|
addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'),
|
||||||
@@ -337,6 +349,21 @@ export class SQLiteStorage implements StorageProvider {
|
|||||||
this.stmts.removeSession.run(address);
|
this.stmts.removeSession.run(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session label aliases ────────────────────────────────
|
||||||
|
|
||||||
|
async getSessionAlias(alias: string): Promise<string | null> {
|
||||||
|
const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined;
|
||||||
|
return row?.canonical ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
||||||
|
this.stmts.saveSessionAlias.run(alias, canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
||||||
|
this.stmts.removeAliasesFor.run(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Trust ────────────────────────────────────────────────
|
// ─── Trust ────────────────────────────────────────────────
|
||||||
|
|
||||||
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.11.1",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*
|
*
|
||||||
* Optional:
|
* Optional:
|
||||||
* DRY_RUN=1 — pack tarballs but do not publish (no token required)
|
* DRY_RUN=1 — pack tarballs but do not publish (no token required)
|
||||||
|
* --only <pkg> — publish one package (e.g. shade-files or @shade/files)
|
||||||
*/
|
*/
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -51,10 +52,27 @@ const PACKAGES = [
|
|||||||
const REGISTRY_HOST = 'gt.zyon.no';
|
const REGISTRY_HOST = 'gt.zyon.no';
|
||||||
const ROOT = join(import.meta.dir, '..');
|
const ROOT = join(import.meta.dir, '..');
|
||||||
|
|
||||||
|
function selectedPackages(argv: string[]): string[] {
|
||||||
|
const onlyIndex = argv.indexOf('--only');
|
||||||
|
if (onlyIndex === -1) return PACKAGES;
|
||||||
|
const requested = argv[onlyIndex + 1];
|
||||||
|
if (!requested) throw new Error('Usage: publish-all.ts --only <shade-files|@shade/files>');
|
||||||
|
const normalized = requested.startsWith('@shade/')
|
||||||
|
? `shade-${requested.slice('@shade/'.length)}`
|
||||||
|
: requested.startsWith('shade-')
|
||||||
|
? requested
|
||||||
|
: `shade-${requested}`;
|
||||||
|
if (!PACKAGES.includes(normalized)) {
|
||||||
|
throw new Error(`Unknown Shade package: ${requested}`);
|
||||||
|
}
|
||||||
|
return [normalized];
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const token = process.env.GITEA_TOKEN;
|
const token = process.env.GITEA_TOKEN;
|
||||||
const user = process.env.GITEA_USER ?? 'Stian';
|
const user = process.env.GITEA_USER ?? 'Stian';
|
||||||
const dryRun = process.env.DRY_RUN === '1';
|
const dryRun = process.env.DRY_RUN === '1';
|
||||||
|
const packagesToPublish = selectedPackages(process.argv.slice(2));
|
||||||
|
|
||||||
if (!token && !dryRun) {
|
if (!token && !dryRun) {
|
||||||
console.error('GITEA_TOKEN is required (or set DRY_RUN=1)');
|
console.error('GITEA_TOKEN is required (or set DRY_RUN=1)');
|
||||||
@@ -64,6 +82,7 @@ async function main() {
|
|||||||
const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`;
|
const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`;
|
||||||
console.log(`Target registry: ${registryUrl}`);
|
console.log(`Target registry: ${registryUrl}`);
|
||||||
console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`);
|
console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`);
|
||||||
|
console.log(`Packages: ${packagesToPublish.join(', ')}`);
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
const npmrcPath = join(ROOT, '.npmrc.publish');
|
const npmrcPath = join(ROOT, '.npmrc.publish');
|
||||||
@@ -86,7 +105,7 @@ async function main() {
|
|||||||
let alreadyPublished = 0;
|
let alreadyPublished = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
for (const pkg of PACKAGES) {
|
for (const pkg of packagesToPublish) {
|
||||||
const pkgDir = join(ROOT, 'packages', pkg);
|
const pkgDir = join(ROOT, 'packages', pkg);
|
||||||
const pkgJsonPath = join(pkgDir, 'package.json');
|
const pkgJsonPath = join(pkgDir, 'package.json');
|
||||||
if (!existsSync(pkgJsonPath)) {
|
if (!existsSync(pkgJsonPath)) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["bun-types"],
|
"types": ["bun-types"],
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@shade/core": ["../../packages/shade-core/src/index.ts"],
|
"@shade/core": ["../../packages/shade-core/src/index.ts"],
|
||||||
"@shade/proto": ["../../packages/shade-proto/src/index.ts"],
|
"@shade/proto": ["../../packages/shade-proto/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user