Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modellen Tools & Services
Oplossingen
Organisaties Ontwikkelaars Internet Service Providers Managed Service Providers AI-in-a-Box
Kenniscentrum
Ondersteuning Documentation Blog Downloads
Bedrijf
Over ons Onderzoek Vacatures Investeren Contact
Inloggen

Channels, envelopes, wraps, claims

Four concepts carry the whole ScaiClip data model. Once these click, the rest of the product is straightforward.

Channels#

A channel is where clips live. Every clip belongs to exactly one channel.

Three kinds ship in v1:

Kind Members Created Use case
personal One user Auto on first WS connect Cross-device clipboard for a single person
directed Two users Idempotent on first use 1:1 private clipboard with a peer
team ScaiKey group Explicit channel.create Shared clipboard for a project or team

Team channels ship in M3. Personal and directed are live today.

A channel carries:

  • A key version (starts at 1, bumps on membership change for team channels).
  • A TTL policy — a per-clip default and a per-channel maximum.
  • A history depth — how many past clips are retained.
  • The set of member devices that hold wraps of the channel key.

The grid never sees the channel key. Every device holds an HPKE-wrapped copy under its own KEM public key. Rotating the key means rewrapping for every current device — expensive, so we do it only on membership change.

Personal channels never rotate — the membership is one user, and that user's devices come and go by re-onboarding, not rekey.

Envelopes#

An envelope is a signed record of a clip. It's what the grid stores and fans out. Envelopes are append-only per channel — every append gets a monotone seq and a grid-assigned timestamp.

Envelope structure (canonical CBOR keys):

gdscript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
1  clip_id      UUIDv7  client-generated, time-ordered
2  channel_id   16 bytes
3  seq          grid-assigned, monotone per channel
4  sender       'usr_alice'  subject the caller authenticated as
5  device_id    'dev_alice_laptop' or 32-char hex
6  client_ts    ms since epoch
7  grid_ts      ms since epoch, grid-assigned
8  type_hint    0 text, 1 rich-text, 2 image, 3 files, 4 other
9  size_class   0 inline · 1 reference
10 cflags       bit0 SENSITIVE · bit1 ONE_SHOT · bit2 PINNED
11 ttl_s        client-requested, grid-clamped
12 key_version  channel's key version at publish time
14 payload_hash SHA-256 of the ciphertext
15 sig          Ed25519 over the canonical CBOR of keys 1-14
20 payload_ref  optional MediaRef pointer (§sharing modes)

The signature covers keys 1-14 only — grid-assigned fields (seq, grid_ts) are omitted from the signature input so grid assignment doesn't invalidate the sig.

Receivers verify the signature against the sender device's sig_pub (fetched from ScaiKey directory, cached locally).

Wraps#

A wrap is an HPKE-encrypted copy of a key. Two kinds:

  • Channel-key wrap — the channel key wrapped for one device. One per (channel, key_version, device). Installed when the device is onboarded.
  • One-shot wrap — a per-clip content key, wrapped for one device. One per (clip_id, device). Only present when the envelope carries ONE_SHOT.

Wraps let the grid deliver the same envelope to every member device without knowing any of their private keys. Every device unwraps its own copy locally.

Wraps live in mod_scaiclip_key_wrap with a wrap_kind discriminator (channel | oneshot | scratch).

Onboarding a new device#

When a new device joins a channel, it doesn't have a channel-key wrap yet. The onboarding flow:

  1. New device calls key.wraps.get — grid returns nothing yet, queues a WrapPending row.
  2. Grid pushes wrap.request to every online sibling device of the same user.
  3. First sibling to answer calls key.wraps.put with a fresh HPKE-wrap of the channel key targeted at the new device's KEM public key.
  4. Grid persists the wrap, marks pending fulfilled, pushes wraps.ready to the new device.
  5. New device re-issues key.wraps.get and receives the wrap.

If no sibling is online, the new device waits — wraps.ready fires when a sibling comes back.

Claims#

A claim is how a ONE_SHOT clip gets consumed exactly once.

Publishing a ONE_SHOT clip requires wraps for every current member device (E_WRAPS_INCOMPLETE if any device is missing). Once published, the ciphertext sits waiting.

The first member device to call clip.claim:

  • Gets the ciphertext + its own one-shot wrap in the response.
  • Wins an atomic INSERT into mod_scaiclip_claim (unique on channel_id, seq).
  • Triggers grid-side crypto-shred of the envelope's ciphertext + every sibling one-shot wrap.
  • Grid fans out clip.claimed to remaining subscribers so their UIs update.

Every subsequent claim call gets E_ALREADY_CLAIMED { claimed_by, at }.

Right primitive for: MFA codes, one-time credentials, single-use tokens, "please rotate this after you've used it" secrets. The user knows: nobody else got it, and it's gone.

What the grid holds#

For clarity — this is what the server's database and cache contain, and nothing more:

  • Envelope rows. Signed, but ciphertext is opaque bytes (or empty + a MediaRef pointer).
  • Wrap rows. HPKE-encrypted opaque bytes.
  • Membership metadata. Which device holds a wrap for which channel.
  • Presence. Last-seen timestamps per device.
  • Audit rows. scaiclip.* events for every state transition.
  • Redis proxy cache for ref_origin transfers, TTL bounded (60 s default).
  • Scratch objects in Garage S3 — again, opaque ciphertext.

Never:

  • Plaintext of any clip.
  • Any channel key.
  • Any device private key.
  • Any decrypted MediaRef payload.

That posture is the whole point.

What's next#

Updated 2026-07-12 00:26:08 View source (.md) rev 5