Skip to content

Aleph Storage Schema

This document defines the wire format used by Aleph Cloud App to store project and deployment history on the Aleph network. It is sufficient to write a third-party recovery tool in any language.

Project schema version: 2 Deployment schema version: 4 (storeRef refactor — see "Artifact storeRef" below) Channel: STASHO_CHANNEL_V1 (renamed from STASHO_CHANNEL_V0 2026-05-20, Decision #117; alpha data on V0 retired. V0 itself was renamed from ALEPH-CLOUDAPP 2026-05-07, Decision #98) Aleph network: mainnet

Readers tolerating older records: the deployment replay path accepts pre-v4 records that carry cid directly in public (no storeRef); it emits a LEGACY_SCHEMA warning per such record but reconstructs the deployment from the inline cid. New writes always emit v4.

Message types

DataAleph typeSignerFrequency
Projects (per wallet, key projects)AGGREGATEUser walletRare — on create/edit/delete
Deployments (per wallet, content type aleph-cloud-deployment)POST + amendsBackend delegateFrequent — 2 per deployment
User security (per wallet, key security)AGGREGATEUser walletOnce per wallet, at first project creation

Projects AGGREGATE

json
{
  "type": "AGGREGATE",
  "channel": "STASHO_CHANNEL_V1",
  "content": {
    "address": "0xUser...",
    "key": "projects",
    "content": {
      "proj_<nanoid12>": {
        "id": "proj_<nanoid12>",
        "schemaVersion": 1,
        "public": {
          "framework": "nextjs-static",
          "deployTarget": "ipfs",
          "createdAt": "<ISO 8601>",
          "updatedAt": "<ISO 8601>",
          "deleted": false
        },
        "encrypted": "<EncryptedEnvelope>"
      }
    },
    "time": "<unix seconds>"
  },
  "signature": "<personal_sign of keccak256(canonical JSON)>",
  "sender": "0xUser..."
}

The encrypted blob decrypts to:

json
{
  "name": "<string>",
  "source": "github" | "upload",
  "repo": "<owner/name>" | null,
  "branch": "<string>" | null,
  "installationId": "<number>" | null,
  "buildCommand": "<string>",
  "outputDir": "<string>"
}

Tombstone: to delete a project, write the entry with public.deleted: true and an empty encrypted blob (all fields set to empty string).

Deployments POST + amends

Each deployment produces two POST messages: a creation and a terminal amend.

Creation POST:

json
{
  "type": "POST",
  "channel": "STASHO_CHANNEL_V1",
  "content": {
    "address": "0xUser...",
    "type": "aleph-cloud-deployment",
    "content": {
      "deploymentId": "deploy_<nanoid12>",
      "projectId": "proj_<nanoid12>",
      "schemaVersion": 4,
      "public": {
        "status": "queued",
        "storeRef": null,
        "url": null,
        "runId": "<number>" | null,
        "runAttempt": "<number>" | null,
        "createdAt": "<ISO 8601>",
        "finishedAt": null
      },
      "encrypted": "<EncryptedEnvelope>"
    },
    "time": "<unix seconds>"
  },
  "signature": "<delegate signature>",
  "sender": "0xBackendDelegate..."
}

Terminal amend (status: "live" or "failed"): Same shape but content.type: "amend" and content.ref: <creation hash>. On a successful pin, the terminal amend's public.storeRef is set to the artifact STORE's item_hash. On failure, storeRef stays null.

Artifact storeRef

The artifact CID is not in the deployment POST. It lives in a separate STORE message, and the deployment POST references it via public.storeRef = <STORE item_hash>. To recover the CID:

  1. Read public.storeRef from the terminal amend POST.
  2. Fetch the STORE: GET <api>/api/v0/messages/<storeRef> (or via the SDK).
  3. The CID is response.content.item_hash (the IPFS protocol-level field).

This indirection exists so a single FORGET on the STORE removes the CID from Aleph's index without touching the deployment POSTs (which survive intact as audit trail).

Tombstoned STOREs (post-FORGET): the GET in step 2 returns 404. Recovery tools should set cid: null and surface a STORE_FORGOTTEN warning. The deployment metadata is preserved; the artifact bytes are unrecoverable through Aleph's index. (Bytes leaked to third-party IPFS gateways may persist indefinitely — encryption is the only durable defense for byte content. See the Data & Privacy section of the application docs.)

Backend-internal cache field: the backend's in-memory cache record additionally carries forgottenAt: ISO timestamp | null, set when the delegate FORGETs the STORE during a project delete. This field is NOT on the wire — recovery tools derive equivalent information by attempting the STORE fetch in step 2.

Cache shape vs. on-Aleph shape (Plan 2b, Decision #123)

The encrypted blob on Aleph is the source of truth for every project field. The backend's in-memory cache (also disk-persisted under /data/cache/cache.enc, AES-256-GCM, Decision #120) holds only a minimized subset of project fields — an orchestration index, not a display source. After Plan 2b ships, the cache shape diverges from the on-Aleph shape.

FieldOn Aleph (in encrypted.ct)In backend cache
framework, deployTarget, createdAt, updatedAtyes (also in public)yes
source, branch, buildCommand, outputDiryesyes (orchestration)
pendingWorkflowPrUrl, pendingWorkflowPrNumberyesyes (orchestration)
name, raw repo, installationIdyesno
repoHashn/ayes (HMAC-SHA256 of repo under CACHE_HMAC_KEY)

The browser sources name / repo from client-side decryption of the user's own Aleph records via useProjectDisplay. installationId is consolidated into a per-wallet store on the backend (pendingInstallations), restored after a fresh boot via POST /api/projects/rehydrate from the browser's localStorage orchestration cache (stasho:orchestration:v1:<wallet>).

This minimization does not change the wire format. The recovery script (scripts/recovery/) and any third-party reader of public Aleph history sees the same on-Aleph shape it always did — the change is purely cache-shape internal to the backend.

Per-project orchestration cache (in-memory only)

In addition to the disk-persisted cache, the backend keeps a per-project in-memory orchestration map (getProjectOrchestration) holding { repo, installationId } for use by route callers (e.g. the deploy-orchestrator's GitHub API calls). This map is never disk-persisted — keeping raw repo strings off disk is the Plan 2b security goal. After a backend restart the map is empty until either (a) a user mutation refills it, or (b) the browser's POST /api/projects/rehydrate call repopulates it on login.

Boot-time binding assertion

The disk cache file (/data/cache/cache.enc) carries plaintext channel and hmacFp fields alongside the encrypted snapshot. On boot, the backend asserts (channel, hmacFp) matches the current configuration and refuses to boot on mismatch — preventing the case where a rotated CACHE_HMAC_KEY would silently produce a repoIndex that mis-keys every lookup.

Project deletion + FORGET

Project deletion is a two-step protocol:

  1. The user signs an AGGREGATE tombstone: public.deleted: true on the project entry. The deployment POSTs (and their referenced STORE messages) remain on Aleph.
  2. After the tombstone broadcasts, the backend's delegate broadcasts ONE FORGET message per delete batching every deployment's storeRef:
json
{
  "type": "FORGET",
  "channel": "STASHO_CHANNEL_V1",
  "content": {
    "address": "0xUser...",
    "hashes": ["<storeRef_1>", "<storeRef_2>", ...],
    "time": "<unix seconds>"
  },
  "signature": "<delegate signature>",
  "sender": "0xBackendDelegate..."
}

FORGET failures are logged but do not roll back the AGGREGATE tombstone — the project deletion is honored even if STORE cleanup fails. Subsequent delete attempts on the same project re-issue FORGETs for any deployments still missing the cache's forgottenAt flag (idempotent).

The FORGET removes the bytes from every Aleph CCN within ~24-48h. Deployment POSTs remain readable post-FORGET — the storeRef hash itself is non-revealing without the dereferenced STORE.

The encrypted blob decrypts to:

json
{
  "commit": "<sha>" | null,
  "commitMessage": "<string>" | null,
  "branch": "<string>" | null,
  "actionsRunUrl": "<string>" | null,
  "error": "<string>" | null
}

Encryption envelope

typescript
interface EncryptedEnvelope {
  v: 3;
  alg: "aes-256-gcm";
  ct: string;     // base64 ciphertext
  iv: string;     // base64 (12 bytes)
  tag: string;    // base64 (16 bytes)
  deks: {
    user: string;       // base64, ECIES-wrapped DEK to user's wallet pubkey
    encryption: string; // base64, ECIES-wrapped DEK to the user's deterministic
                         // wallet-derived encryption key (Decision #117).
                         // Mandatory since v3.
  };
}

Schema version note: v: 1 envelopes used deks.session (a rotating browser-generated keypair, Decision #98). v: 2 replaced deks.session with deks.encryption (the deterministic wallet-derived key, Decision #117) and still carried a deks.backend slot. v: 3 (Thread 2 step 3, 2026-05-22) removes deks.backend — the backend is no longer a standing decrypt recipient on the public ledger — and makes deks.encryption mandatory. The current backend only writes v: 3.

A v: 1 record's deks.session was wrapped to an ephemeral browser-generated keypair not derivable from the wallet — a third-party recovery tool cannot decrypt it via deks.session; fall back to deks.user. v: 1 records exist only in alpha data on STASHO_CHANNEL_V0 — a retired channel (alpha data retired with Decision #117). A recovery tool targeting current data should query STASHO_CHANNEL_V1; STASHO_CHANNEL_V0 holds no live records.

Pre-v: 3 records are not readable by the current backend: it boots from an encrypted disk cache and, on Aleph replay, treats any envelope below v: 3 as legacy-skipped (a LEGACY_ENVELOPE_SKIPPED warning). The standalone recovery script remains version-agnostic — it reads deks.user regardless of envelope version, so it can still recover pre-v: 3 records given the wallet private key.

Decryption algorithm

A v: 3 envelope has two independent recipients. Pick whichever you have a private key for:

  1. Take envelope.deks.user (or deks.encryption), base64-decode → wrappedDek
  2. ECIES-decrypt wrappedDek with the corresponding secp256k1 private key → dek (32 bytes)
  3. base64-decode envelope.ct, envelope.iv, envelope.tag
  4. AES-256-GCM decrypt with key=dek, iv=iv, ciphertext=ct, authTag=tag
  5. Result is UTF-8 JSON; parse to get the cleartext fields

deks.user — requires the user's wallet private key. Used by the standalone recovery script (the only key the user must supply).

deks.encryption — requires the user's deterministic wallet-derived encryption key E. The browser derives E locally without a backend round-trip, enabling in-browser decryption of deployment history.

Legacy v: 1 / v: 2 envelopes additionally carried a deks.backend recipient (the backend's standing secp256k1 key). That slot was removed in v: 3 (2026-05-22); the backend is no longer a standing decrypt recipient on the public ledger. A recovery tool reading a pre-v: 3 record may still see a deks.backend field — it is not usable without the backend's private key, so decrypt via deks.user instead.

Deriving the deterministic encryption key (deks.encryption recipient)

E.priv = keccak256(personal_sign(derivationMessage)) where:

  • personal_sign is EIP-191 personal signing: the wallet signs "\x19Ethereum Signed Message:\n" + byteLen(msg) + msg, where byteLen(msg) is the UTF-8 byte length of the message as a decimal string — not the character count. This matters: the message's title line contains an em-dash (, U+2014) which is 3 UTF-8 bytes, so byte length exceeds character count. Returns a 65-byte signature (r || s || v).
  • keccak256 of the raw 65-byte signature bytes yields a 32-byte value used directly as the secp256k1 private key.
  • E.pub is the uncompressed secp256k1 public key derived from E.priv (standard Ethereum / OpenSSL derivation: 04 || X || Y, 65 bytes total; as a hex string, a 0x04 prefix followed by 128 hex chars — 132 chars in all).

Canonical derivation message (exact UTF-8 bytes, lines joined by \n, with no trailing newline after Version: 1):

Stasho Cloud — encryption key

Sign to derive your personal encryption key. This key lets your
browser decrypt your deployment history.

This signature does not authorize any transaction, payment, or
spending. It is free and safe.

Wallet: <checksummed EIP-55 address>
Version: 1

The Wallet: line uses the EIP-55 checksummed form of the user's Ethereum address (mixed-case hex). The derivation message is deterministic: same wallet always produces the same E. E never rotates.

The user's E.pub is registered on the user's security aggregate under the encryptionKey field (see "Security aggregate encryptionKey" below) so the backend can wrap deks.encryption without the user's private key.

Security aggregate encryptionKey

The user's security aggregate body carries an encryptionKey sibling field alongside authorizations[]. It is registered at the same time the user's first project is created (bundled with the security aggregate write) and is never updated unless explicitly rotated.

json
{
  "authorizations": [ "…" ],
  "encryptionKey": {
    "pubkey": "0x04<128 hex chars>",
    "scheme": "wallet-sig-v1",
    "registeredAt": "<ISO-8601>"
  }
}
FieldTypeDescription
pubkeystringUncompressed secp256k1 public key of E: 0x04 prefix followed by 128 hex chars (64-byte X ∥ Y). This is the ECIES encryption recipient for deks.encryption.
scheme"wallet-sig-v1"Derivation scheme identifier. wallet-sig-v1 means E.priv = keccak256(EIP-191 personal_sign(derivationMessage)) as described in "Deriving the deterministic encryption key" above.
registeredAtstringISO-8601 timestamp of registration, set by the client at write time.

encryptionKey is an ECIES recipient only — it is never used as a transaction signer and never appears in authorizations[]. The backend reads encryptionKey.pubkey from the live security aggregate to wrap deks.encryption on every new deployment write (including OIDC-driven reports from GitHub Actions), so the browser can decrypt locally without an active wallet session.

Session-key authorization filter

Browser-generated session keys (Decision #98) are authorized in the user's security aggregate with a tighter filter than the backend delegate. Note: session keys are courier signers only — their ECIES-recipient role (formerly deks.session) moved to the deterministic encryptionKey in Decision #117.

FieldValueNotes
address0x… (42-char Ethereum address, derived from the session pubkey)What Aleph's CCN matches against the wire-level recovered sender
pubkey0x04… (uncompressed secp256k1 pubkey, 132-char hex)Optional, Decision #108+. Populated only on session-key authorizations created after 2026-05-09. Previously the backend wrapped deks.session to this key; that ECIES-recipient role moved to the deterministic encryptionKey (Decision #117), so this field is now informational only — the address field is sufficient for CCN authorization matching. The backend's delegate authorization entry never carries this field.
types["POST", "AGGREGATE"]
channels[STASHO_CHANNEL_V1]Pinned to our channel; a stolen key cannot forge writes outside it
aggregate_keys["projects", "deployments"]Excludes security — the load-bearing security property: a stolen session key cannot edit the user's security posture (cannot authorize more keys, cannot revoke the user's wallet, cannot lock the user out)
post_types[]Empty = match-all within the allowed types (Decision #70 semantics)

Amend chain resolution

For each deployment, build a chain of POSTs:

  1. Find the creation POST (type === "aleph-cloud-deployment")
  2. Find amend POSTs whose ref field equals the creation's item_hash
  3. Follow forward: each amend's hash may be referenced by another amend; the leaf is the latest
  4. Use the leaf's content as the authoritative deployment state

When two amends share the same ref, pick the one with the largest time (last-write-wins).

Cryptographic primitives

  • AES-256-GCM — Node crypto.createCipheriv("aes-256-gcm", key, iv) semantics; 12-byte IV, 16-byte tag
  • ECIES — secp256k1 ECDH + HKDF-SHA256 + AES-256-GCM, as implemented by eciesjs (https://github.com/ecies/js); same scheme is used by ecies/py and ecies/rs for cross-language compatibility
  • secp256k1 — same curve as Ethereum signing
  • Addresskeccak256(uncompressed_pubkey[1:])[12:] (standard Ethereum derivation)

Canonical signing digest

User-signed AGGREGATE messages use this canonical JSON form before hashing:

javascript
JSON.stringify({
  type: msg.type,
  channel: msg.channel,
  content: {
    address: msg.content.address,
    ...(key !== undefined ? { key } : {}),
    ...(type !== undefined ? { type } : {}),
    ...(ref !== undefined ? { ref } : {}),
    content: msg.content.content,
    time: msg.content.time,
  },
})

The 32-byte keccak256 of this string is then signed via Ethereum personal_sign (EIP-191), which wraps it in the \x19Ethereum Signed Message:\n prefix. Signature recovery on the reader side must apply the same wrapping.