This document is an overview of the internals of the Safecloud ecosystem. It can serve as the canonical context file for new LLM sessions working on the Safecloud project. It covers architecture, cryptographic design, all method signatures, and the role of OpenClaiming (OCP) in the system.
Safecloud is a decentralised, encrypted, self-pricing storage network built on top of the Q/Intercoin platform. Files are split into encrypted chunks and distributed across browser tabs and servers. No node ever sees plaintext — every chunk is encrypted before it leaves the owner's device, and keys are never transmitted.
The three network roles are:
| Role | Code | Where it runs |
|---|---|---|
| Cloud | Q.Safecloud.Client |
Browser — file owner/consumer SDK |
| Jets | Q.Safecloud.Jets (client) + node/Safecloud/.js (server) |
Browser (socket client) + Node.js (routing server) |
| Drops | Q.Safecloud.Drops |
Browser tabs volunteering IndexedDB storage |
Jets never see plaintext. Drops never see plaintext. Only the Cloud (owner or authorised grantee) has the keys to decrypt.
This is a pure JavaScript project. Node.js (server) and Web Browser (client). PHP is present only for compatibility — e.g. verifying OCP payment or authorisation claims that arrive at a PHP web server before being forwarded.
All crypto runs through:
Q.Data.*— primitive operations:digest,hkdf,derive,importKey,encrypt,decrypt,canonicalize, and the tree structuresMerkle,Prolly,BloomQ.Crypto.*— typed signing:internalKeypair,sign,verify,delegate,verifyDelegatedQ.Crypto.OpenClaim.*— OCP claim envelope:sign,verify,canonicalize,resolveQ.Crypto.OpenClaim.EVM.*— EIP-712 payment + authorisation extensions
Browser implementations use SubtleCrypto and dynamic import() of noble-curves
(nist.js, secp256k1.js, sha3.js, eip712.js, encoder.js).
Node.js uses crypto built-in and crypto-js for keccak256.
The core primitive for the entire key hierarchy. Every key in Safecloud is derived from a root secret using this function — nothing is stored except the root.
seed: Uint8Array (must be binary — decode hex/base64 first)
label: String (HKDF info / domain separation label)
options.size: Number (output bytes, default 32)
options.context: String (HKDF salt = SHA-256(context), default "")
Returns: Promise<Uint8Array>
Internally: salt = SHA-256(context), then HKDF-SHA256 with the label as info.
This is byte-identical across browser (SubtleCrypto) and Node.js (crypto built-in)
and PHP.
Domain separation is critical. Every label used in Safecloud is unique and
hard-coded in _internal.js:
safecloud.encryption.root → encryptionRoot (32 bytes) from rootKey
safecloud.access.root → accessRoot (32 bytes) from rootKey
Three parallel N-ary trees from one rootCid (binary default):
Merkle tree — built bottom up from chunk CIDs (public, cacheable)
Encryption key tree — built top down via chained Q.Crypto.delegate (Cloud only)
Access level tree — built top down via chained Q.Crypto.delegate (Jets enforce)
Same link path array navigates all three:
["track","data","0","1"] → Merkle node / encryption subtreeKey / access grant
safecloud.track.data.{S}.{E} → subtreeKey for data track chunk range [S, E)
safecloud.track.index → key for the index track (single encrypted chunk)
safecloud.chunk.key.{i} → AES-256-GCM key for chunk i (relative)
safecloud.chunk.iv.{i} → 12-byte IV for chunk i (relative)
safecloud.read.{word} → label for a read-delegation capability
safecloud.write.{word} → label for a write-delegation capability
safecloud.admin.{word} → label for an admin-delegation capability
SHA-256 (and others). Used directly for CID computation and as a building block
inside derive and the tree structures.
Low-level HKDF-SHA256. Called by derive. Rarely used directly.
AES-256-GCM encryption. All three fields are base64 strings.
key: CryptoKey (from Q.Data.importKey)
plaintext: Uint8Array
options.iv: Uint8Array|String (base64 or Uint8Array — MUST be supplied for convergent encryption)
options.additional: Uint8Array (AAD — additional authenticated data)
Returns: { iv: base64, ciphertext: base64, tag: base64 }
The tag is always separated out. Safecloud passes tag and ciphertext
separately everywhere; this matters for decrypt and CID computation.
options.tag: base64 String (16-byte auth tag — appended to ciphertext before decryption)
options.additional: Uint8Array (must match AAD used at encrypt time, or decryption fails)
Imports raw bytes as an AES-GCM key (default). Used before every
encrypt/decrypt call.
RFC 8785 / JCS canonical JSON (keys sorted recursively, deterministic number
serialisation). Used by OCP claim signing. Byte-identical to PHP
Q_Data::canonicalize().
Used in Safecloud to commit to the ordered set of CIDs in a file. The Merkle root is stored in the public manifest and allows any party to verify that a chunk they received was included in the original upload.
Leaf hashing uses domain-separation prefix bytes:
- Leaves:
SHA-256( 0x00 || leafBytes ) - Internal nodes:
SHA-256( 0x01 || leftHash || rightHash )
Q.Data.Merkle.build(leaves) → Promise<rootHex>
// leaves: Array<Uint8Array|String>
Q.Data.Merkle.proof(leaves, index) → Promise<{ proof: Array, rootHex: String }>
// proof: Array<{ hex: String, side: 'left'|'right' }>
Q.Data.Merkle.verify(leaf, proof, rootHex) → Promise<Boolean>Used by Drops and Jets to efficiently reconcile chunk inventories after a reconnect. Structural sharing means subtrees with equal roots are skipped entirely — diff is O(diff × log n), not O(n).
Q.Data.Prolly.build(entries, store) → Promise<rootHash>
// entries: Array<{ key: String, value: String }>
// store: { get(hash)->Promise<node|null>, put(hash,node)->Promise } (optional, defaults to in-memory)
Q.Data.Prolly.get(rootHash, key, store) → Promise<String|null>
Q.Data.Prolly.set(rootHash, key, value, store) → Promise<newRootHash>
Q.Data.Prolly.delete(rootHash, key, store) → Promise<newRootHash>
Q.Data.Prolly.diff(rootHashA, rootHashB, store) → Promise<Array<{key, before, after}>>Used on first contact between a Drop and a Jet (before any Prolly tree state is shared). The Drop sends a compact Bloom filter of all its CIDs; the Jet can probe for specific CIDs before routing.
Q.Data.Bloom.create(n, p) → Promise<BloomFilter>
// n: expected element count, p: false positive rate (e.g. 0.01)
Q.Data.Bloom.fromElements(elements, p) → Promise<BloomFilter>
// elements: Array<String> (CID strings), p: false positive rate
Q.Data.Bloom.fromBytes(uint8array, k, m, count) → Promise<BloomFilter>
Q.Data.Bloom.fromBase64(base64str, k, m, count) → Promise<BloomFilter>
// BloomFilter instance methods:
bloom.add(element) → Promise (adds via SHA-256 hash)
bloom.test(element) → Promise<Boolean>
bloom.toBase64() → String
bloom.toBytes() → Uint8ArrayThe only place a secret becomes a private key. Deterministic, no randomness, no storage.
options.secret: Uint8Array (32 bytes recommended)
options.format: 'ES256'|'EIP712'
// ES256 (P-256):
// privateKey = HKDF-SHA256(secret, "q.crypto.p256.private-key", 32 bytes)
// publicKey = P-256 uncompressed point (65 bytes: 0x04 || X || Y)
// EIP712 (secp256k1):
// seed = keccak256("q.crypto.k256.private-key" || secret)
// privateKey = seed mod curveOrder
// publicKey = secp256k1 uncompressed point (65 bytes)
// address = "0x" + last 20 bytes of keccak256(publicKey[1..64])
Returns: { format, curve, hashAlg, privateKey, publicKey, [address] }Signs a typed message. ES256 signs SHA-256(canonical JSON payload). EIP712
signs the EIP-712 struct hash.
options.secret: Uint8Array
options.format: 'ES256'|'EIP712'
options.message: Object
options.types: Object (EIP-712 type definitions)
options.primaryType: String
options.domain: Object (optional)
// ES256 returns DER-encoded signature
// EIP712 returns 65-byte r||s||v (v = 27 + recovery bit)
Returns: { format, curve, hashAlg, domain, primaryType, digest, signature, signatureHex, publicKey, [address] }options.format: 'ES256'|'EIP712'
options.message: Object
options.types: Object
options.primaryType: String
options.domain: Object
options.signature: Uint8Array|String
options.publicKey: Uint8Array (ES256 only)
options.address: String (EIP712 — expected signer "0x...")
options.recovered: Object (optional — .address written here on EIP712 recovery)Derives a child secret and creates a signed proof that the parent authorised it.
Used by Cloud.grant() to create scoped access tokens.
options.rootSecret: Uint8Array (parent secret)
options.label: String (e.g. "safecloud.read.content")
options.context: String (JSON-encoded scope: rootCid, start, end, exp)
options.format: 'ES256'|'EIP712'
// Internally:
// childSecret = Q.Data.derive(rootSecret, "q.crypto.delegate." + label, {size:32})
// parentKeypair = Q.Crypto.internalKeypair(rootSecret, format)
// statement = { parent, label, issuedTime, context, secretHash }
// proof = Q.Crypto.sign({ secret: rootSecret, message: statement, ... })
Returns: {
label: String,
context: String,
secret: Uint8Array, // the derived child secret
statement: Object,
proof: Object // the signed proof (contains signature + publicKey)
}Verifies one delegation step: the child secret matches statement.secretHash,
the statement was signed by the declared parent.
options.format: 'ES256'|'EIP712'
options.statement: Object
options.signature: Uint8Array|String
options.derivedSecret: Uint8Array
options.parentPublicKey: Uint8Array (ES256 only)
options.domain: Object (EIP712 only)
options.recovered: Object (optional)Every file has one master secret: the rootKey (32 random bytes). From it,
everything is derived deterministically using Q.Data.derive. The owner must
store the rootKey securely — it is not in the manifest.
rootKey (32 bytes — owner must keep this secret)
│
├─ derive("safecloud.encryption.root")
│ → encryptionRoot (32 bytes)
│ │
│ ├─ Q.Crypto.internalKeypair(ES256)
│ │ → encryptionRootKeypair
│ │ publicKey → stored in manifest
│ │ used to sign the bindingProof
│ │
│ └─ derive("safecloud.subtree.{S}.{E}")
│ → subtreeKey (32 bytes, covers chunks S..E-1)
│ │
│ ├─ derive("safecloud.chunk.key.{i}") (i = relative index)
│ │ → AES-256-GCM key for chunk i
│ │
│ └─ derive("safecloud.chunk.iv.{i}")
│ → 12-byte IV for chunk i
│
└─ derive("safecloud.access.root")
→ accessRootBytes (32 bytes)
│
└─ Q.Crypto.internalKeypair(ES256)
→ accessRootKeypair
publicKey → stored in manifest
used for future access control (v2+)
The subtreeKey is the unit of delegation. When the owner grants access to
[start, end), they derive subtreeKey = derive(encryptionRoot, "safecloud.subtree.{start}.{end}") and run Q.Crypto.delegate on it to produce an OCP capability.
The grantee receives:
capability.secret— base64 of the subtreeKey (to decrypt chunks)capability.read/capability.write/capability.admin— OCP delegation proofs (to present to Jets)
The grantee derives chunk keys using the relative index within their range:
chunkKey = derive(subtreeKey, "safecloud.chunk.key.{relIdx}") where
relIdx = absIdx - start. This means the keys are identical whether derived
by the owner or the grantee.
All key derivation is deterministic. Same rootKey + same content → same chunk keys → same ciphertext → same CIDs. This enables natural deduplication: if the same file is uploaded twice with the same rootKey, the Jets/Drops can recognise the identical CIDs and skip redundant storage.
Each chunk's identity is a CIDv1 string: 'b' + base32(bytes) where:
bytes[0] = 0x01 CIDv1
bytes[1] = 0x55 raw codec
bytes[2] = 0x12 sha2-256 multihash
bytes[3] = 0x20 32-byte digest
bytes[4..35]= SHA-256(ciphertext || tag)
The CID is computed over the ciphertext concatenated with the auth tag — not the plaintext. This means:
- CIDs are safe to share publicly (no plaintext leakage)
- The CID commits to the full authenticated ciphertext including the GCM tag
- Drops can verify they have the right chunk without any keys
Q.Safecloud.Client._internal.chunkCid(ciphertextB64, tagB64) and
Q.Safecloud.Drops.cidFromData(arrayBuffer) must produce identical values.
Every chunk is encrypted with AAD:
aad = UTF-8("safecloud.chunk:" + absoluteIndex)
The AAD binds each ciphertext to its absolute position in the file. Even if an attacker somehow obtained two chunk keys, they could not swap chunks — the AAD mismatch would cause GCM authentication to fail.
Note: chunk keys and IVs use the relative index within the subtree, but AAD uses the absolute index. This is intentional: the relative index is what the grantee needs to derive keys, while the absolute index is a unique file-wide position that prevents cross-chunk confusion.
The manifest is fully public and shareable. It contains everything needed to locate, verify, and request chunks — but nothing needed to decrypt them.
{
v: 1,
rootCid: String, // Merkle root of all chunk CIDs
encryptionRootPublicKey: String, // base64 — verifies bindingProof
accessRootPublicKey: String, // base64 — for access control (v2+)
bindingProof: {
statement: { encryptionRootPublicKey, accessRootPublicKey, rootCid },
proof: Object // Q.Crypto.sign result — verifies these two keys belong together
},
chunkCount: Number,
chunkSize: Number, // bytes per chunk (last chunk may be smaller)
size: Number, // total file bytes
name: String,
type: String, // MIME type
created: Number, // Unix timestamp
jurisdiction: String|null,
aiAttestation: Object|null
}The bindingProof is signed by encryptionRoot and commits to both public
keys and the rootCid. Anyone can verify the two roots belong to the same file
without knowing any secrets.
Browser only. All methods follow Q.promisify convention: they accept an
optional callback as the last argument and also return a Q.Promise.
Number — default chunk size in bytes (256 × 1024 = 262144).
Number — current manifest version (1).
function(type: String, word: String) → Number — maps a Streams-compatible
level word (e.g. 'content') to its numeric level for a given type
('read', 'write', 'admin').
Chunks, encrypts, and uploads a file to Safecloud via Jets.
file: {
data: Blob, // file content
name: String,
type: String, // MIME type (optional)
tags: Array // optional content tags
}
options: {
key: Uint8Array, // existing rootKey (re-upload / update). Omit to generate random key
chunkSize: Number, // default: Q.Safecloud.Client.defaultChunkSize
authorizations: Array, // OCP auth claims to send to Jets (v1: accepted but not verified)
payments: Array, // OCP payment claims (v1: accepted but not verified)
jurisdiction: String, // stored in manifest, null in v1
aiAttestation: Object, // stored in manifest, null in v1
onProgress: function(uploaded, total)
}
callback(err, {
manifest: Object, // fully public — safe to store anywhere
rootKey: String // base64 — MUST be kept secret by the caller
})Steps:
- Generate or accept rootKey
- Derive
encryptionRootandaccessRootBytesfrom rootKey - Derive keypairs for both roots
- Read blob → split into chunks of
chunkSize - Derive
subtreeKeyfor[0, chunkCount) - For each chunk (in parallel):
- Derive
chunkKey[i]andchunkIV[i](relative index) - Encrypt with AAD =
"safecloud.chunk:" + i - Compute CIDv1 from
ciphertext || tag
- Derive
- Build Merkle tree over ordered CIDs →
rootCid - Sign binding statement with
encryptionRoot - Upload all chunks via
Q.Safecloud.Jets.chunkPut() - Return manifest + rootKey
Downloads, Merkle-verifies, and decrypts a chunk range.
manifest: Object // from store()
capability: {
// Owner path:
rootKey: String // base64
// Delegated path:
secret: String, // base64 subtreeKey for [start, end)
read: Object, // OCP delegation proof
start: Number,
end: Number
}
options: {
start: Number, // first chunk (default 0)
end: Number, // last chunk exclusive (default manifest.chunkCount)
authorizations: Array,
payments: Array,
onProgress: function(decrypted, total)
}
callback(err, Blob)Steps:
- Resolve
subtreeKey:- Owner:
derive(encryptionRoot, "safecloud.subtree.0.N") - Delegated:
capability.secretIS the subtreeKey; verify OCP proof covers all requested chunks first
- Owner:
- Fetch chunks via
Q.Safecloud.Jets.chunkGet({ rootCid, start, end }) - For each chunk: verify Merkle proof against
manifest.rootCid - Decrypt using relative index
i = absIdx - start:chunkKey = derive(subtreeKey, "safecloud.chunk.key.i")chunkIV = derive(subtreeKey, "safecloud.chunk.iv.i")- AAD =
"safecloud.chunk:" + absIdx(absolute)
- Reassemble decrypted chunks into a
Blob
Delegates access to a chunk range. Returns a capability that a grantee passes
to fetch() and to Jets as OCP authorization proofs.
manifest: Object
rootKey: String|Uint8Array // base64 or Uint8Array
options: {
start: Number, // first chunk (default 0)
end: Number, // last chunk exclusive (default manifest.chunkCount)
readLevel: String, // Streams word e.g. 'content' (always granted)
writeLevel: String, // optional
adminLevel: String, // optional
format: 'ES256'|'EIP712', // default 'ES256'
exp: Number // Unix timestamp expiry
}
callback(err, {
secret: String, // base64 subtreeKey — grantee uses for decryption
read: Object, // Q.Crypto.delegate proof
write: Object|null,
admin: Object|null,
manifest: Object,
start: Number,
end: Number
})Steps:
- Derive
encryptionRootfrom rootKey - Derive
subtreeKey = derive(encryptionRoot, "safecloud.subtree.{start}.{end}") - Build context:
JSON.stringify({ rootCid, start, end, [exp] }) - For each requested level, call
Q.Crypto.delegate({ rootSecret: subtreeKey, label, context, format }) - Return capability with
secret = base64(subtreeKey)plus delegation proofs
Turns this browser tab into a temporary Drop by storing received encrypted chunks in IndexedDB and announcing them to Jets. Chunks are always encrypted — resharing never exposes plaintext.
chunks: Array<{ cid, ciphertext, iv, tag, tags }>
options: { authorizations, payments }
callback(err, { announced: Number })Browser only. Shared socket client used by both Cloud (uploaders/downloaders)
and Drops (storage providers). All methods follow Q.promisify convention.
Override to use a specific server URL instead of Q.nodeUrl().
Connects (or reuses existing connection) to the Jet server. Safe to call multiple times. Auto-reconnects with exponential backoff + jitter (±30%, base 500ms, max 30s) on disconnect.
callback(err, Q.Socket)Registers this browser tab as a Drop. Sends the stable dropId (derived from
Q.clientId() + sessionStorage) so the Jet can recognise a reconnecting tab.
info: { storage: { GB: Number } }
callback(err, { dropId: String })Announces updated storage stats and optionally a new Prolly root or Bloom filter.
info: {
storage: { GB: Number },
used: Number, // bytes currently used
prollyRoot: String, // hex Prolly root (optional)
bloomFilter: String // base64 Bloom filter bytes (optional, cold-start only)
}
callback(err)Signals intentional offline. Clears the stable dropId from sessionStorage.
callback(err)Sends encrypted chunks to the Jet for distribution to Drops.
chunks: Array<{
cid: String, // CIDv1
iv: String, // base64
ciphertext: String, // base64
tag: String, // base64
size: Number,
tags: Array
}>
options: {
authorizations: Array, // OCP authorization claims
payments: Array // OCP payment claims
}
callback(err, { results: Array<Object|false> })Requests encrypted chunks from the Jet. Supports range requests.
// Range form (preferred):
payload: { rootCid: String, start: Number, end: Number }
// Legacy CID list form:
payload: { cids: Array<String> }
options: { authorizations: Array, payments: Array }
callback(err, {
chunks: Array<{
cid: String,
iv: String, // base64
ciphertext: String, // base64
tag: String, // base64
proof: Array // Merkle proof [{hex, side}]
}|null>
})v1 stub. Sends a Jet-to-Jet peering request.
info: { url: String }
callback(err)| Event | Arguments | Fired when |
|---|---|---|
Q.Safecloud.Jets.onConnect |
(Q.Socket) |
Socket connects |
Q.Safecloud.Jets.onDisconnect |
() |
Socket disconnects |
Q.Safecloud.Jets.onDropPut |
(payload, ack) |
Jet pushes store request to this Drop |
Q.Safecloud.Jets.onDropGet |
(payload, ack) |
Jet pushes retrieve request to this Drop |
Q.Safecloud.Jets.onDropChallenge |
(payload, ack) |
Jet issues proof-of-storage challenge |
Q.Safecloud.Jets.onDropSlashed |
(payload) |
This Drop's stake is slashed |
Jets.js wires onDropPut and onDropGet directly to Q.Safecloud.Drops.put()
and Q.Safecloud.Drops.get() so Drops don't need to listen to these events manually.
Browser only. Stores and serves encrypted chunks using IndexedDB. All
methods follow Q.promisify convention.
Sets the maximum storage this Drop is willing to offer. May trigger a browser storage-persistence permission request.
sizeGB: Number
callback(err, Boolean) // true if persist grantedComputes CIDv1 from the raw encrypted chunk data. Must match Cloud._internal.chunkCid().
arrayBuffer: ArrayBuffer // the encrypted chunk (ciphertext || tag concatenated)
callback(err, cidString)Stores one or more encrypted chunks in IndexedDB. Evicts LRU chunks if storage limit would be exceeded.
chunks: Array<{
iv: String, // base64 IV
data: ArrayBuffer, // raw encrypted bytes
tags: Array // optional content tags
}>
options: {
authorizations: Array, // OCP claims (v1: stubbed)
payments: Array // OCP claims (v1: stubbed)
}
callback(err, {
results: Array<{ cid: String, iv: String, size: Number }|false>
})Retrieves encrypted chunks by CID. Missing chunks are null (order preserved).
Updates accessed timestamp for LRU purposes.
cids: Array<String>
options: { authorizations: Array, payments: Array }
callback(err, {
chunks: Array<{ iv: String, data: ArrayBuffer }|null>
})v1: always returns true. Will be wired to Q.Crypto.OpenClaim.verify() in v0.5.
v1: always returns true. Will check monotonic (payer, line) payment amounts in v0.5.
The Jet server routes chunks between Cloud clients and Drop storage providers. It never decrypts anything.
var Safe = require('./Safecloud/');
Safe.listen(options);
// Returns: { internal: httpServer, socket: socketServer }Reads config from:
Q.Config.get(['Safe', 'node', 'host'])Q.Config.get(['Safe', 'node', 'port'])Q.Config.get(['Safe', 'drop', 'offlineGraceMs'])(default 60000)
Safe.drops // { dropId → dropRecord }
// dropRecord: {
// dropId, socketId, socket, clientId, userId,
// storage: { GB }, used,
// prollyRoot: String|null,
// offlineSince: Number|null,
// registeredAt, reconnectedAt
// }Selects Drops to store or serve a set of CIDs. v1: round-robin, up to
options.replication (default 2) Drops.
Emits an event to a Drop socket and waits for the ack. Rejects on timeout (default 10s).
After reconnect, diffs the Jet's stored Prolly root against the Drop's reported
root. If equal: no-op. If different: calls Q.Data.Prolly.diff() to find the
delta and emits 'dropSync'. On first contact (no jet-side root): emits
'dropColdSync'.
| Event | Arguments | Fired when |
|---|---|---|
'dropRegister' |
(drop) |
New Drop registers for first time |
'dropReconnect' |
(drop) |
Existing Drop reconnects |
'dropOffline' |
(drop) |
Drop socket disconnects unexpectedly |
'dropDisconnect' |
(drop) |
Drop intentionally disconnects or evicted |
'dropAnnounce' |
(drop) |
Drop announces updated stats |
'dropBloom' |
(drop, bloomFilterBase64) |
Drop sends Bloom filter (cold start) |
'dropSync' |
(drop, changes) |
Prolly diff completed after reconnect |
'dropColdSync' |
(drop, prollyRoot) |
First contact with this Drop (no prior state) |
'dropSlash' |
(drop, payload) |
PHP signals a verified Proof of Corruption |
All socket events under the /Safecloud/ namespace:
| Event (client → server) | Handler |
|---|---|
Safecloud/drop/register |
Register or reconnect Drop |
Safecloud/drop/announce |
Update stats, Prolly root, Bloom filter |
Safecloud/drop/disconnect |
Remove Drop from registry |
Safecloud/chunk/put |
Route encrypted chunks to Drops |
Safecloud/chunk/get |
Fetch chunks from Drops (CID list or range) |
Safecloud/chunk/challenge |
Forward proof-of-storage challenge to a Drop |
Safecloud/peer/connect |
v1 stub — Jet-to-Jet peering |
| Event (PHP → server, internal) | Handler |
|---|---|
Safecloud/drop/slash |
Signal stake slashing for a Drop |
OCP is the authorisation and payment layer that governs who may store and retrieve chunks. All chunk routing goes through Jets, so OCP verification lives primarily in the Jet server.
An OpenClaim is a JSON envelope:
{
ocp: 1,
iss: "example.com", // issuer
sub: "alice", // subject
stm: { ... }, // statement payload
nbf: 1700000000, // not-before Unix timestamp
exp: 1800000000, // expiry Unix timestamp
key: ["data:key/es256;base64,..."], // array of signer public key URIs
sig: ["<base64 raw r||s 64 bytes>"] // corresponding signatures
}All signing uses raw r||s 64 bytes (IEEE P1363), NOT DER. Canonical JSON
(RFC 8785) with sig stripped is what is actually signed.
1. Access authorization (Q.Crypto.OpenClaim.sign/verify — ES256)
When Cloud.grant() calls Q.Crypto.delegate(), it produces an OCP-like proof.
The capability.read, capability.write, capability.admin objects from
grant() are delegation proofs that the grantee includes in options.authorizations
when calling Cloud.fetch() and Cloud.store(), which pass them to Jets.
Jets will (in v0.5) call Q.Crypto.OpenClaim.verify(claim) to check:
- The proof is validly signed
- The
contextfield encodes arootCid,start,endthat covers the requested chunks exphas not passed
2. Payment (Q.Crypto.OpenClaim.EVM.sign/verify — EIP-712 secp256k1)
Payment claims are EIP-712 typed-data signed by an Ethereum wallet. They
authorise a metered payment from a payer address to storage-provider addresses
for up to max tokens on a given line (like a trustline — monotonically
incrementing, so each request just presents a higher max).
The canonical verifyingContract is 0x9999...9999 on the configured chain.
// Payment claim shape:
{
ocp: 1,
payer: "0x...", // who pays
token: "0x...", // ERC-20 token address
recipients: ["0x..."], // Drop operators to pay
max: 1000, // max tokens authorised
line: 1, // trustline ID (monotonic nonce)
nbf: 0,
exp: 9999999999,
chainId: 1,
contract: "0x9999...9999",
key: ["data:key/eip712,0x..."],
sig: ["<base64 65-byte r||s||v>"]
}Verification: Q.Crypto.OpenClaim.EVM.verify(claim, sig, expectedAddress)
3. Proof of Storage challenge/response
Jets issue random challenges: "prove you still have CID X by signing
(cid, nonce) with your Drop key." The Drop's OCP key (ES256, derived from
a Drop-specific secret) signs the response. In v0.5 this will replace the
current placeholder iv in the challenge ack.
| URI format | Meaning |
|---|---|
data:key/es256;base64,<SPKI-DER-base64> |
P-256 public key (inline) |
data:key/eip712,<0x-address> |
Ethereum address (secp256k1 — recover via ecrecover) |
https://example.com/...#path |
URL-hosted key document (fetched + cached 60s) |
Signs a claim with ES256. Derives keypair from secret, appends the SPKI key
URI to key[] and the raw r||s signature to sig[]. Multisig-safe (pass
existing = { keys, signatures } to add alongside existing signers).
Verifies ES256 and/or EIP712 signatures. Policy: null = any 1 valid,
N = at least N valid, {mode:'all'} = all keys must sign.
Builds the EIP-712 typed-data digest for a Payment or Authorization claim. Auto-detects claim type by field presence.
Signs an EVM (Payment or Authorization) claim from a secret. Delegates to
Q.Crypto.sign({ format: 'EIP712', ... }).
Verifies an EVM claim signature. Delegates to Q.Crypto.verify({ format: 'EIP712', ... }).
The following stubs exist and need to be filled for v0.5:
| File | Location | What to wire |
|---|---|---|
node/Safecloud/.js |
Safecloud/chunk/put handler |
Q.Crypto.OpenClaim.verify(options.authorizations[i]) |
node/Safecloud/.js |
Safecloud/chunk/get handler |
same |
node/Safecloud/.js |
Safecloud/chunk/challenge ack |
Sign (cid, nonce) with Drop's OCP ES256 key |
Drops.js |
checkAuthorization() |
Q.Crypto.OpenClaim.verify(claims) |
Drops.js |
checkPayment() |
Q.Crypto.OpenClaim.EVM.verify(claims) — monotonic line check |
plugins/Safecloud/
├── node/
│ └── Safe.js ← Jet server (Node.js)
└── web/js/
├── Safecloud/
│ ├── Cloud.js ← Q.Method.define wiring for Cloud
│ ├── Jets.js ← Shared socket.io client (Cloud + Drops)
│ ├── Drops.js ← Browser Drop node (IndexedDB)
│ └── DataTrees.js ← Lazy-loads Merkle/Prolly/Bloom on Q.Data
└── methods/Q/Safecloud/Cloud/
├── _internal.js ← Key derivation, CID, chunking helpers
├── store.js ← Encrypt + upload pipeline
├── fetch.js ← Download + Merkle-verify + decrypt
├── grant.js ← Capability delegation
└── reshare.js ← Re-announce held chunks to Jets
plugins/Q/web/js/
├── methods/Q/
│ ├── Data/
│ │ ├── derive.js, digest.js, hkdf.js
│ │ ├── encrypt.js, decrypt.js
│ │ ├── importKey.js, generateKey.js
│ │ ├── canonicalize.js, compress.js, decompress.js
│ │ ├── sign.js, verify.js
│ │ ├── Merkle/ (build, verify, proof, _internal)
│ │ ├── Prolly/ (build, get, set, delete, diff, _internal)
│ │ └── Bloom/ (create, fromElements, fromBytes, fromBase64, _internal)
│ └── Crypto/
│ ├── internalKeypair.js, sign.js, verify.js
│ ├── delegate.js, verifyDelegated.js
│ └── OpenClaim/
│ ├── canonicalize.js, sign.js, verify.js, resolve.js
│ └── EVM/
│ ├── hashTypedData.js, sign.js, verify.js
└── crypto/
├── eip712.js ← standalone EIP-712 encoder (also used by PHP)
├── secp256k1.js ← noble-curves secp256k1
├── nist.js ← noble-curves P-256
├── sha3.js ← keccak_256
└── encoder.js ← DER/ASN.1 helpers
classes/Q/
├── Crypto/
│ ├── EIP712.php
│ └── OpenClaim/ (EVM.php)
└── OpenClaim.php, OpenClaim_EVM.php
- OCP authorization verification in Jets —
Safecloud/chunk/putandSafecloud/chunk/gethandlers - OCP payment verification in Jets — same handlers,
options.payments - Drop proof-of-storage challenge signing — replace placeholder in
Jets.onDropChallenge - Range request CID routing index — Jets need a
rootCid → [cid...]index for range gets - Prolly-backed routing — use Prolly tree coverage to weight Drop selection
- Erasure coding — currently replication only
- Jet-to-Jet peering —
Safecloud/peer/connectstub Q.Data.Merkle/Prolly/Bloomswap instore.js/fetch.js/Drops.js— current code uses some inline helpers; replace with canonicalQ.Data.*method calls (see client.zip analysis)Drops.buildProllyTree()— replace manual implementation withQ.Data.Prolly.build(entries, idbStore)Drops.buildBloomFilter()— replace withQ.Data.Bloom.fromElements(cids)
This section documents the Q platform classes that Safecloud builds on top of, with notes on exactly where and how each is used. These classes are part of the Q.js plugin framework shared across all Qbix/Intercoin apps.
Q.Promise is Q's canonical Promise wrapper. It is interchangeable with native
Promise and thenable. All Safecloud async operations return a Q.Promise.
The framework also exposes:
Q.resolve(value)— wraps a value in a resolved promiseQ.reject(error)— wraps an error in a rejected promiseQ.Promise.all([...])— equivalent toPromise.all
In Safecloud, Q.Promise is the return type of every method in Q.Safecloud.Client,
Q.Safecloud.Jets, and Q.Safecloud.Drops. All crypto primitives (Q.Data.*,
Q.Crypto.*) also return Q.Promise.
Q.promisify wraps a callback-style function so it can be called either as
a callback-accepting function or as a Promise-returning function. This is
how every public method in Q.Safecloud.Client, Q.Safecloud.Jets, and Q.Safecloud.Drops
is defined.
// Declaration pattern:
Q.Safecloud.Client.store = Q.promisify(function (file, options, callback) {
// ... do async work ...
callback(null, result); // or callback(err)
}, false, 2); // false = don't return `this`; 2 = number of required non-callback args
// Call as callback:
Q.Safecloud.Client.store(file, options, function (err, result) { ... });
// Call as Promise:
Q.Safecloud.Client.store(file, options).then(function (result) { ... });
// Call with no options (promisify handles optional trailing args):
Q.Safecloud.Client.store(file).then(...);The argCount parameter tells Q.promisify how many non-callback arguments
the function expects, so it can correctly detect whether the caller passed a
callback. For example store has argCount=2 because it expects (file, options).
Every Q.Data.*, Q.Crypto.*, and Q.Safecloud.Client.* file is wrapped in
Q.exports(function(Q, _) { ... }). This is the Q framework's mechanism
for declaring method files that are loaded on demand.
Q— the global Q namespace, available in every method file_— a shared internal helpers object loaded viaoptions.require, passed to all method files in the same sub-namespace (e.g. allQ.Data.Merkle.*files share the same_internal.jshelper object_)
In Safecloud:
Q/Safecloud/Cloud/_internal.jsexports the_object used bystore.js,fetch.js,grant.js, andreshare.jsQ/Data/Merkle/_internal.jsexports shared Merkle helpersQ/Data/Prolly/_internal.jsexports shared Prolly helpersQ/Data/Bloom/_internal.jsexports theBloomFilterclass and helpers
Q.extend is Q's equivalent of Object.assign. It merges properties from
source objects into target, returning the target. Used throughout Safecloud:
// In store.js — merging context into delegation options:
var context = JSON.stringify(Q.extend(
{ rootCid: manifest.rootCid, start: start, end: end },
options.exp ? { exp: options.exp } : {}
));
// In node/Safecloud/.js — building a Drop record:
var drop = Q.extend({
dropId: dropId,
socketId: client.id,
...
}, info);Q.Event is Q's lightweight observable / event system. It differs from
Node.js EventEmitter in that each event is a first-class object with
its own .set() / .handle() / .remove() methods, and handlers can be
named for later removal.
// Create:
var myEvent = new Q.Event();
// Register a named handler:
myEvent.set(function (payload, ack) {
// handle the event
}, 'myHandler');
// Fire the event:
myEvent.handle(payload, ack);
// Remove a specific handler by name:
myEvent.remove('myHandler');Where used in Safecloud:
Q.Safecloud.Jets exposes six Q.Event instances:
| Property | Fires when |
|---|---|
Q.Safecloud.Jets.onConnect |
Socket connects to Jet server |
Q.Safecloud.Jets.onDisconnect |
Socket disconnects |
Q.Safecloud.Jets.onDropPut |
Jet pushes a store request to this Drop |
Q.Safecloud.Jets.onDropGet |
Jet pushes a retrieve request to this Drop |
Q.Safecloud.Jets.onDropChallenge |
Jet issues a proof-of-storage challenge |
Q.Safecloud.Jets.onDropSlashed |
This Drop's stake is slashed by the network |
Jets.js wires onDropPut and onDropGet directly to Q.Safecloud.Drops by
registering named handlers on them:
Q.Safecloud.Jets.onDropPut.set(function (payload, ack) {
Q.Safecloud.Drops.put(chunks, payload.options, function (err, result) {
ack && ack(err ? { error: err.message } : null, result);
});
}, 'Q.Safecloud.Jets.onDropPut');This means application code can add its own handlers to these events without removing the built-in wiring:
// Application-level monitoring:
Q.Safecloud.Jets.onDropSlashed.set(function (payload) {
console.warn('Drop slashed, reason:', payload.reason);
}, 'myApp.slashMonitor');Node.js: Q.makeEventEmitter(Safe)
On the server side, node/Safecloud/.js calls Q.makeEventEmitter(Safe) to give
the Safe object standard emit / on / once / off methods. This is
Q's thin wrapper over Node's EventEmitter.
// Listening to Jet-level events in application code:
Safe.on('dropRegister', function (drop) {
console.log('New Drop:', drop.dropId, drop.storage.GB + ' GB');
});
Safe.on('dropSync', function (drop, changes) {
// changes: Array<{ key: cid, before: value|null, after: value|null }>
// Re-route chunks that disappeared from the Drop's Prolly tree
var disappeared = changes.filter(function (c) { return c.after === null; });
disappeared.forEach(function (c) { replicateChunk(c.key); });
});Q.Socket wraps a socket.io client connection with Q conventions. It is
the transport layer for all browser↔Jet communication.
// Connect to a namespace on a server:
Q.Socket.connect(namespace, url, function (err, qs) {
// qs.socket — the raw socket.io socket
// qs.socket.emit(event, payload, ackCallback)
// qs.socket.on(event, handler)
});Where used in Safecloud:
Jets.js calls Q.Socket.connect('/Safecloud/', url, ...) to open the /Safecloud/
namespace. All subsequent socket events (Safecloud/chunk/put, Safecloud/drop/register,
etc.) are emitted and received on qs.socket.
Key design decisions:
- One shared socket — both
Q.Safecloud.Client(uploader/downloader) andQ.Safecloud.Drops(storage provider) share the same/Safecloud/namespace connection managed byQ.Safecloud.Jets. There is no separate socket for each role. - Queue before connect —
_withSocket(fn)buffers calls made before the socket is ready, then drains the queue on connect. - Drop identity is stable —
_dropId()is derived fromQ.clientId()and stored insessionStorage, so a reconnecting tab presents the samedropIdand the Jet server restores its record rather than creating a new Drop.
Q.Socket.reconnect(ns, url, options) is a static helper added by Jets.js
for general-purpose reconnect-with-backoff logic. It is not specific to Safe.
All methods return Q.Promise and also accept an optional callback as the last
argument. Browser implementations use SubtleCrypto. Node.js uses crypto built-in.
Q.Data.toBase64(bytes) → String // Uint8Array → base64
Q.Data.fromBase64(str) → Uint8Array // base64 → Uint8Array
Q.Data.toHex(bytes) → String // Uint8Array → lowercase hex
Q.Data.fromHex(str) → Uint8Array // hex → Uint8Array
Q.Data.toUint8Array(v) → Uint8Array // coerce ArrayBuffer/etc → Uint8ArrayQ.Data.digest(algorithm, payload)
// algorithm: 'SHA-256' | 'SHA-384' | 'SHA-512'
// payload: Uint8Array | ArrayBuffer | String (strings are UTF-8 encoded)
// → Promise<Uint8Array>
Q.Data.hkdf(ikm, salt, info, length)
// ikm: Uint8Array input key material
// salt: Uint8Array
// info: String context label
// length: Number output bytes (default 32)
// → Promise<Uint8Array>
Q.Data.derive(seed, label, options)
// seed: Uint8Array | ArrayBuffer (MUST be binary — not a string)
// label: String (HKDF info / domain label — must be unique)
// options.size: Number (output bytes, default 32)
// options.context: String (salt = SHA-256(context), default "")
// → Promise<Uint8Array>Q.Data.importKey(keyBytes, algo)
// keyBytes: Uint8Array (32 bytes for AES-256)
// algo: { name, length, usages } (default: AES-GCM 256 encrypt/decrypt)
// → Promise<CryptoKey>
Q.Data.generateKey(algo)
// algo: { name, namedCurve, hash } (default: ECDSA P-256 SHA-256)
// → Promise<{ publicKey: base64, privateKey: base64, algorithm }>
Q.Data.encrypt(key, plaintext, options)
// key: CryptoKey (AES-GCM)
// plaintext: Uint8Array
// options.iv: Uint8Array | base64 String — REQUIRED for convergent encryption
// options.additional: Uint8Array — AAD (authenticated but not encrypted)
// → Promise<{ iv: base64, ciphertext: base64, tag: base64 }>
Q.Data.decrypt(key, ivBase64, ciphertextBase64, options)
// key: CryptoKey (AES-GCM)
// ivBase64: base64 String (12 bytes)
// ciphertextBase64: base64 String (ciphertext WITHOUT tag)
// options.tag: base64 String (16-byte GCM auth tag — appended before decryption)
// options.additional: Uint8Array — must match AAD used at encrypt time
// → Promise<Uint8Array>Q.Data.sign(data, privateKeyPKCS8Strings, algo)
// data: String (UTF-8 encoded before signing)
// privateKeyPKCS8Strings: Array<base64 PKCS8>
// algo: { name, namedCurve, hash } (default ECDSA P-256 SHA-256)
// → Promise<Array<ArrayBuffer>> (one per key — convert with Q.Data.toBase64)
Q.Data.verify(data, publicKeyRawStrings, signatures, algo)
// data: String
// publicKeyRawStrings: Array<base64 raw public key>
// signatures: Array<ArrayBuffer | base64 String>
// → Promise<Array<Boolean>> (one per key)Q.Data.canonicalize(object)
// RFC 8785 / JCS — keys sorted recursively, stable number serialisation
// NaN and Infinity throw
// → String (synchronous)Q.Data.compress(data, callback, options)
// data: String | Object (Objects are JSON-stringified)
// options.algorithm: 'gzip' | 'deflate' (default: 'gzip')
// → Promise<ArrayBuffer>
Q.Data.decompress(buffer, callback, options)
// buffer: ArrayBuffer
// → Promise<String>Q.Data.Merkle.build(leaves, callback)
// leaves: Array<Uint8Array | String> — strings UTF-8 encoded
// → Promise<String> hex root
Q.Data.Merkle.proof(leaves, index, callback)
// → Promise<{ proof: Array<{hex:String, side:'left'|'right'}>, rootHex: String }>
Q.Data.Merkle.verify(leaf, proof, rootHex, callback)
// leaf: Uint8Array | String
// proof: Array<{hex, side}>
// → Promise<Boolean>Domain separation: leaves = SHA-256(0x00 || bytes), internal = SHA-256(0x01 || left || right).
Q.Data.Prolly.build(entries, store, callback)
// entries: Array<{ key: String, value: String }> — sorted by key internally
// store: { get(hash)→Promise, put(hash,node)→Promise } | null (defaults to in-memory)
// → Promise<String> hex root hash
Q.Data.Prolly.get(rootHash, key, store, callback)
// → Promise<String | null>
Q.Data.Prolly.set(rootHash, key, value, store, callback)
// → Promise<String> new root hash
Q.Data.Prolly.delete(rootHash, key, store, callback)
// → Promise<String | null> new root hash (null = empty tree)
Q.Data.Prolly.diff(rootHashA, rootHashB, store, callback)
// → Promise<Array<{ key: String, before: String|null, after: String|null }>>Boundary detection: a key is a chunk boundary when SHA-256(key)[0] < 16
(branching factor 16, ~16 keys per leaf node). Node identity = SHA-256(JSON(node)).
Q.Data.Bloom.create(n, p, callback)
// n: expected elements, p: false positive rate (default 0.01)
// → Promise<BloomFilter>
Q.Data.Bloom.fromElements(elements, p, callback)
// elements: Array<String> (all SHA-256 calls run in parallel)
// → Promise<BloomFilter>
Q.Data.Bloom.fromBytes(uint8array, callback)
// → Promise<BloomFilter>
Q.Data.Bloom.fromBase64(base64, callback)
// → Promise<BloomFilter>
// BloomFilter instance:
filter.add(element) → Promise (mutates filter)
filter.has(element) → Promise<Boolean>
filter.hasMany(elements) → Promise<Array<Boolean>>
filter.merge(otherFilter) → Promise (in-place OR of bit arrays)
filter.falsePositiveRate() → Number
filter.elementCount() → Number
filter.toBytes() → Uint8Array
filter.toBase64() → StringHashing: Kirsch-Mitzenmacher double hashing — two SHA-256 calls (prefixed 0x00 and
0x01) → k positions via h_i(x) = (h1 + i·h2) mod m.
All methods return Q.Promise. All secrets must be Uint8Array.
Q.Crypto.internalKeypair(options)
// options.secret: Uint8Array
// options.format: 'ES256' | 'EIP712'
// → Promise<{ format, curve, hashAlg, privateKey, publicKey, [address] }>
// ES256: privateKey = HKDF-SHA256(secret, "q.crypto.p256.private-key", 32)
// EIP712: privateKey = keccak256("q.crypto.k256.private-key" || secret) mod n
Q.Crypto.sign(options)
// options.secret: Uint8Array
// options.format: 'ES256' | 'EIP712'
// options.message: Object
// options.types: Object (EIP-712 type defs, also used for ES256 payload)
// options.primaryType: String
// options.domain: Object (optional)
// → Promise<{ format, curve, hashAlg, domain, primaryType, digest, signature,
// signatureHex, publicKey, [address] }>
// ES256: signature = DER-encoded ECDSA over SHA-256(canonical JSON)
// EIP712: signature = 65-byte r||s||v over EIP-712 struct hash
Q.Crypto.verify(options)
// options.format: 'ES256' | 'EIP712'
// options.domain, types, primaryType, message: same as sign
// options.signature: Uint8Array | String
// options.publicKey: Uint8Array (ES256 required)
// options.address: String (EIP712 — expected "0x..." address)
// options.recovered: Object (optional — .address written here on EIP712)
// → Promise<Boolean>
Q.Crypto.delegate(options)
// options.rootSecret: Uint8Array
// options.label: String (e.g. "safecloud.read.content")
// options.context: String (JSON-encoded scope, stored in statement)
// options.format: 'ES256' | 'EIP712'
// → Promise<{ label, context, secret: Uint8Array, statement: Object, proof: Object }>
// Derives childSecret = Q.Data.derive(rootSecret, "q.crypto.delegate." + label)
// Signs { parent, label, issuedTime, context, secretHash } with rootSecret
Q.Crypto.verifyDelegated(options)
// options.format: 'ES256' | 'EIP712'
// options.statement: Object
// options.signature: Uint8Array | String
// options.derivedSecret: Uint8Array
// options.parentPublicKey: Uint8Array (ES256)
// options.domain: Object (EIP712)
// options.recovered: Object (optional)
// → Promise<Boolean>
// Checks: secretHash matches, signature is valid, signer = declared parentOpenClaim (OCP) is the claim envelope format used for authorisation and
payment in Safecloud. It is distinct from Q.Crypto.sign — the signing payload
is the whole claim (with sig stripped), not a typed-data wrapper.
Critical difference from Q.Crypto.sign:
Q.Crypto.sign |
Q.Crypto.OpenClaim.sign |
|
|---|---|---|
| Payload | {domain, primaryType, types, message} |
The claim object itself (sig stripped) |
| Signature format | DER (ES256) or r||s||v (EIP712) | Raw r||s 64 bytes (IEEE P1363) |
| Used for | delegate() capability proofs |
OCP claim envelopes sent to Jets/Drops |
Never mix these two signing paths.
Q.Crypto.OpenClaim.canonicalize(claim)
// Strips sig field, applies Q.Data.canonicalize (RFC 8785)
// → Promise<String> canonical JSON
Q.Crypto.OpenClaim.sign(claim, secret, existing)
// claim: Object OCP claim payload
// secret: Uint8Array signing secret
// existing: { keys, signatures } for adding to an existing multisig claim
// Derives ES256 keypair, appends "data:key/es256;base64,<SPKI>" to key[]
// Signs SHA-256(canonical) with noble p256 → raw r||s (not DER)
// → Promise<Object> claim with key[] and sig[] populated
Q.Crypto.OpenClaim.verify(claim, policy)
// claim: OCP claim with key[] and sig[]
// policy: null = at least 1 valid | Number N = at least N | { mode:'all' } = all keys
// | { minValid: N }
// ES256 keys verified via SubtleCrypto with raw r||s (IEEE P1363)
// EIP712 keys delegate to Q.Crypto.OpenClaim.EVM.verify
// → Promise<Boolean>
Q.Crypto.OpenClaim.resolve(keyStr)
// Resolves a key URI to a parsed key object, cached 60s
// 'data:key/es256;base64,...' → { fmt: 'ES256', value: Uint8Array (SPKI DER) }
// 'data:key/eip712,0x...' → { fmt: 'EIP712', value: '0x...' }
// 'https://...' → fetch + follow fragment path → { fmt, value }
// → Promise<{ fmt, value } | Array | null>Used for payment flows where the payer is an Ethereum wallet. The verifying contract is Intercoin's payment gateway on the configured chain.
Q.Crypto.OpenClaim.EVM.hashTypedData(claim)
// Auto-detects Payment (has payer+token+line) vs Authorization (has authority+subject)
// → Promise<{ digest: Uint8Array(32), payload: { domain, primaryType, types, value } }>
Q.Crypto.OpenClaim.EVM.sign(claim, secret, existing)
// Builds typed payload, calls Q.Crypto.sign({format:'EIP712',...})
// Stores derived Ethereum address as "data:key/eip712,<address>" in key[]
// Signature = 65-byte r||s||v
// → Promise<Object> claim with key[] and sig[] populated
Q.Crypto.OpenClaim.EVM.verify(claim, signature, expectedAddress, recovered)
// signature: Uint8Array | hex String | base64 String (65-byte r||s||v)
// expectedAddress: "0x..." | undefined
// recovered: optional Object — recovered.address written here
// → Promise<Boolean>Payment claim fields:
{
ocp: 1,
payer: "0x...", // who pays (recovered from EIP-712 sig)
token: "0x...", // ERC-20 token address (or zero address for native)
recipients: ["0x...", ...], // Drop operator addresses to pay
max: BigInt | Number, // max tokens authorised on this line
line: BigInt | Number, // monotonic trustline ID — ever-increasing nonce
nbf: Number, // not-before Unix timestamp
exp: Number, // expiry Unix timestamp
chainId: Number,
contract: "0x...", // verifyingContract on-chain
key: ["data:key/eip712,0x..."],
sig: ["<base64 65-byte r||s||v>"]
}Authorization claim fields:
{
ocp: 1,
authority: "0x...", // who grants the permission
subject: "0x...", // who receives it
actors: ["0x...", ...], // optional actor whitelist
roles: ["admin", ...],
actions: ["read", ...],
constraints: [{ key, op, value }, ...],
contexts: [{ type, value }, ...],
nbf: Number,
exp: Number,
chainId: Number,
contract: "0x...",
key: ["data:key/eip712,0x..."],
sig: ["<base64 65-byte r||s||v>"]
}A Jet or Drop receiving a request with payment claims should:
- Verify the OCP claim signature —
Q.Crypto.OpenClaim.EVM.verify(claim, sig, payer) - Check expiry —
claim.exp > Date.now() / 1000 - Check the trustline is monotonically increasing — the
(payer, line)pair must have amaxgreater than the last seen value. Use a local cache. - Check on-chain balance — query the ERC-20 contract to confirm the payer
has sufficient balance and allowance on the
contractaddress. - Accept the work and update the cached
(payer, line) → maxvalue.
// In-memory cache: Map<"payer:line", maxSeen>
var _lineCache = {};
function verifyPayment(claim, callback) {
Q.Crypto.OpenClaim.EVM.verify(claim, claim.sig[0], claim.payer)
.then(function (valid) {
if (!valid) throw new Error('Invalid payment signature');
var now = Math.floor(Date.now() / 1000);
if (claim.exp && claim.exp < now) throw new Error('Payment claim expired');
if (claim.nbf && claim.nbf > now) throw new Error('Payment claim not yet valid');
var lineKey = claim.payer + ':' + claim.line;
var prevMax = _lineCache[lineKey] || 0;
if (Number(claim.max) <= prevMax) throw new Error('Stale payment claim (max not increasing)');
// Check on-chain balance before accepting
return checkOnChainBalance(claim);
})
.then(function (sufficient) {
if (!sufficient) throw new Error('Insufficient on-chain balance');
_lineCache[claim.payer + ':' + claim.line] = Number(claim.max);
callback(null, true);
})
.catch(callback);
}The Jet (Node.js) or a PHP gateway can verify the payer's balance and allowance:
// Minimal ethers-free balance check via JSON-RPC:
function checkOnChainBalance(claim) {
// ERC-20 balanceOf(address) selector = 0x70a08231
var data = '0x70a08231' + claim.payer.replace(/^0x/i, '').padStart(64, '0');
return fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1,
method: 'eth_call',
params: [{ to: claim.token, data: data }, 'latest']
})
})
.then(function (r) { return r.json(); })
.then(function (json) {
var balance = BigInt(json.result || '0x0');
return balance >= BigInt(claim.max);
});
}For OCP ES256 access-authorization claims (the delegation proofs from
Cloud.grant()):
function verifyAuthorization(capability, requestedChunks) {
// capability.read is the delegation proof from Cloud.grant()
var proof = capability.read;
if (!proof || !proof.statement) return Promise.resolve(false);
// 1. Check the context encodes a range covering all requested chunks
var ctx;
try { ctx = JSON.parse(proof.statement.context); } catch (e) { return Promise.resolve(false); }
if (ctx.rootCid !== manifest.rootCid) return Promise.resolve(false);
for (var i = 0; i < requestedChunks.length; i++) {
if (requestedChunks[i] < ctx.start || requestedChunks[i] >= ctx.end) {
return Promise.resolve(false);
}
}
// 2. Check expiry
var now = Math.floor(Date.now() / 1000);
if (ctx.exp && ctx.exp < now) return Promise.resolve(false);
// 3. Verify the delegation signature
// proof.publicKey is the parent's ES256 public key
return Q.Crypto.verifyDelegated({
format: 'ES256',
statement: proof.statement,
signature: proof.signature,
derivedSecret: Q.Data.fromBase64(capability.secret),
parentPublicKey: Q.Data.fromBase64(proof.publicKey)
});
}Currently both are stubs returning true. In v0.5 they will call:
Q.Safecloud.Drops.checkAuthorization = function (authorizations, method, payload, options) {
if (!authorizations || !authorizations.length) return false;
return Q.Crypto.OpenClaim.verify(authorizations[0], { minValid: 1 });
};
Q.Safecloud.Drops.checkPayment = function (payments, options) {
if (!payments || !payments.length) return false;
var claim = payments[0];
return Q.Crypto.OpenClaim.EVM.verify(claim, claim.sig[0], claim.payer)
.then(function (valid) {
if (!valid) return false;
var lineKey = claim.payer + ':' + claim.line;
var prev = Q.Safecloud.Drops._lineCache[lineKey] || 0;
if (Number(claim.max) <= prev) return false;
Q.Safecloud.Drops._lineCache[lineKey] = Number(claim.max);
return true;
});
};Both Jets and Drops should cache verified claims for their exp duration to
avoid re-running signature verification on every chunk request:
// Cache key: canonical JSON of the claim (without sig)
// Cache value: { valid: Boolean, exp: Number (Unix timestamp) }
var _claimCache = {};
function verifyWithCache(claim) {
var key = Q.Data.canonicalize(Object.assign({}, claim, { sig: undefined }));
var cached = _claimCache[key];
var now = Math.floor(Date.now() / 1000);
if (cached && cached.exp > now) return Q.Promise.resolve(cached.valid);
return Q.Crypto.OpenClaim.verify(claim).then(function (valid) {
_claimCache[key] = { valid: valid, exp: claim.exp || (now + 3600) };
return valid;
});
}