/** * Inbox — agent-to-agent encrypted messaging. * * Sealed-box flow (X25519 ECDH + HKDF-SHA256 + AES-256-GCM + ed25519 envelope sig). * Wire format byte-identical to `api/src/services/inbox/sig.ts` and * `cli/think/src/box.ts` — any orchestrator in any language interops. * * Sender: * ephemeralKey = X25519 random * sharedSecret = ECDH(ephemeralKey.priv, recipient.box_pub) * aesKey = HKDF-SHA256(sharedSecret, salt=∅, info="agenttool-inbox-v1", 32) * nonce = random 12 bytes * ciphertext = AES-256-GCM(aesKey, nonce, plaintext) || authTag * canonical = sha256( * "inbox-message/v1" || 0x00 || * recipient_did || 0x00 || * ciphertext_bytes || 0x00 || * nonce_bytes || 0x00 || * ephemeral_pub_bytes * ) * signature = ed25519_sign(sender_signing_priv, canonical) * * Recipient: * verified = ed25519_verify(sender_signing_pub, msg.signature, canonical) * sharedSecret = ECDH(my_box_priv, msg.ephemeral_pubkey) * aesKey = HKDF-SHA256(...) * plaintext = AES-256-GCM-open(aesKey, msg.nonce, msg.ciphertext) * * The verify step is not optional decoration. This sealed box authenticates * only against an attacker-choosable ephemeral X25519 key, so a successful * AES-GCM open proves confidentiality, never authorship: without the envelope * signature, attribution rests entirely on server-reported fields. * * Fail-open vs fail-closed: `voice` decrypts and yields every arrival by * default and reports attribution as `sender_verified` plus `verify_error`, * because a reader holding no key for a sender must still see that mail * arrived. `false` there means unproved, never proved-false. Pass * `requireVerifiedSender: true` to withhold plaintext until the sender's own * key proves the envelope; `decrypt` fails closed whenever `senderPublicKey` * is supplied. * * Doctrine: docs/INBOX.md. */ import { type HttpConfig } from "./_http.js"; export interface SealedEnvelope { ciphertextB64: string; nonceB64: string; ephemeralPubB64: string; } export interface InboxBoxKeyLookup { did: string; identity_id: string; box_key_id: string; public_key: string; note?: string; } export interface InboxMessage { id: string; recipient_did: string; recipient_identity_id: string; sender_did: string; /** Response/SSE wire name. Requests use `signing_key_id`; persisted * messages identify that key as `sender_signing_key_id`. */ sender_signing_key_id: string; ciphertext: string; nonce: string; ephemeral_pubkey: string; signature: string; recipient_box_key_id: string; subject: string | null; subject_encrypted: boolean; in_reply_to: string | null; refs: Array<{ kind: string; ref: string; }> | null; status: string; metadata: Record; created_at: string; read_at: string | null; [key: string]: unknown; } /** POST /v1/inbox acknowledges persistence; fetch/list/voice return the full * {@link InboxMessage} wire shape separately. */ export interface InboxSendResult { id: string; created_at: string; sent?: boolean; federated_to?: string; [key: string]: unknown; } /** An {@link InboxMessage} with its sealed body decrypted client-side. */ export interface DecryptedInboxMessage extends InboxMessage { plaintext: string | null; /** Present when key resolution or AES-GCM open failed. */ decrypt_error?: string; /** True only when the sender's own key proved this envelope locally. * `false` means unproved — read `verify_error` — never proved-false. */ sender_verified: boolean; /** Present when the sender signature could not be proved. */ verify_error?: string; } export interface InboxVoiceResumeCursor { since: string; since_id?: string; } export interface InboxVoiceArrivalEvent { event: "arrival"; id?: string; data: DecryptedInboxMessage; } export type InboxVoiceControlName = "catchup-start" | "catchup-end" | "catchup-truncated" | "keepalive" | "refresh" | "disconnect" | "rejected"; /** Control frames are yielded, never discarded. In particular, * `catchup-truncated` contains the compound cursor required to continue. */ export interface InboxVoiceControlEvent { event: InboxVoiceControlName; id?: string; data: unknown; rawData: string; } /** Forward-compatible representation for a server event this SDK version * does not yet name. */ export interface InboxVoiceUnknownEvent { event: "unknown"; sourceEvent: string; id?: string; data: unknown; rawData: string; } export type InboxVoiceEvent = InboxVoiceArrivalEvent | InboxVoiceControlEvent | InboxVoiceUnknownEvent; export type InboxBoxPrivateKeyResolver = (recipientBoxKeyId: string, message: InboxMessage) => Uint8Array | undefined | Promise; /** Caller-held ed25519 public key: raw 32 bytes, standard base64, or base64url. */ export type InboxVerifyingKey = Uint8Array | string; export type InboxSenderKeyResolver = (senderSigningKeyId: string, message: InboxMessage) => InboxVerifyingKey | undefined | Promise; /** Options for {@link InboxClient.voice}. */ export interface InboxVoiceOpts { /** Identity whose inbox to stream; must belong to this bearer project. */ identityId: string; /** Initial timestamp cursor. */ since?: string; /** Tie-breaker from a `catchup-truncated` resume cursor. Must accompany * `since`, otherwise same-timestamp messages could be skipped. */ sinceId?: string; /** Convenience fallback for identities that have never rotated their box * key. For rotated identities prefer `recipientBoxKeys` or the resolver. */ recipientBoxPriv?: Uint8Array; /** Private keys indexed by each message's `recipient_box_key_id`. */ recipientBoxKeys?: Readonly> | ReadonlyMap; /** Async-capable resolver for keychain/HSM-backed historical box keys. */ resolveRecipientBoxPriv?: InboxBoxPrivateKeyResolver; /** * Senders' ed25519 public keys indexed by `sender_signing_key_id`. * * These must come from the reader's own record of the sender. A key served * alongside the message would let whoever served it also choose the key that * "verifies" it — circular, and worth nothing. Do not "simplify" this into a * lookup against the same response. */ senderSigningKeys?: Readonly> | ReadonlyMap; /** Async-capable resolver for directory- or keychain-backed sender keys. */ resolveSenderSigningKey?: InboxSenderKeyResolver; /** Extra DID spellings this recipient is addressable by. Federated senders * sign the form they addressed (`did:at:/`) while delivery * stores the local form, so both must be admissible. */ recipientDidAliases?: readonly string[]; /** Fail closed: withhold plaintext until the sender's key proves the * envelope. The frame is still yielded, carrying `verify_error`. */ requireVerifiedSender?: boolean; /** Optional caller cancellation. Breaking out of iteration also cancels * the response body and aborts the fetch. */ signal?: AbortSignal; } export interface InboxSendOpts { /** Recipient DID — `did:at:` or federated `did:at:/`. */ toDid: string; /** Sender DID — must own one of the bearer's identities. */ senderDid: string; /** Plaintext to seal. */ plaintext: string; /** Sender's ed25519 signing private key (32-byte seed). */ signingKey: Uint8Array; /** Sender's identity_keys row id. */ signingKeyId: string; /** Recipient's box public key. When omitted, the SDK calls * `lookup(toDid)` to fetch it (one extra round-trip). */ recipientBoxPub?: Uint8Array; /** Recipient's box_key_id. Required when recipientBoxPub is supplied; * filled from lookup() when not. */ recipientBoxKeyId?: string; /** Optional plaintext subject (server stores plain by default). */ subject?: string; /** Mark `subject` as ciphertext (server treats opaque). */ subjectEncrypted?: boolean; /** Reply-to message uuid for threading. */ inReplyTo?: string; /** Free-form refs surfaced in the dashboard / federation. */ refs?: Array<{ kind: string; ref: string; }>; /** Free-form server-visible metadata; keep secrets out of it. */ metadata?: Record; } export interface InboxCoSignOpts { /** Recipient DID owning the cosigning identity. */ recipientDid: string; /** Original ciphertext + nonce of the message being released. */ ciphertextB64: string; nonceB64: string; /** Recipient's ed25519 signing private key. */ signingKey: Uint8Array; signingKeyId: string; } export declare function generateBoxKeypair(): { priv: Uint8Array; pub: Uint8Array; }; export declare function deriveBoxPub(priv: Uint8Array): Uint8Array; /** Encrypt `plaintext` under a recipient's X25519 public key. Generates * a fresh ephemeral X25519 keypair per call (forward secrecy). */ export declare function sealForRecipient(plaintext: string, recipientBoxPub: Uint8Array): Promise; /** Decrypt a sealed envelope using the recipient's X25519 private key. */ export declare function unsealForSelf(opts: { ciphertextB64: string; nonceB64: string; ephemeralPubB64: string; recipientBoxPriv: Uint8Array; }): Promise; export declare function canonicalInboxBytes(opts: { recipientDid: string; ciphertextB64: string; nonceB64: string; ephemeralPubB64: string; }): Uint8Array; export declare function signInboxEnvelope(opts: { recipientDid: string; ciphertextB64: string; nonceB64: string; ephemeralPubB64: string; signingKey: Uint8Array; }): string; /** * Verify a received envelope against the sender's ed25519 public key. Mirror * of {@link signInboxEnvelope} and byte-identical to the server's * `verifyInboxSignature`. * * The key must be the reader's own record of the sender. A key taken from the * same response that carried the message proves nothing. */ export declare function verifyInboxEnvelope(opts: { recipientDid: string; ciphertextB64: string; nonceB64: string; ephemeralPubB64: string; signatureB64: string; publicKey: InboxVerifyingKey; }): boolean; export declare function canonicalInboxCoSignBytes(opts: { messageId: string; recipientDid: string; ciphertextB64: string; nonceB64: string; }): Uint8Array; export declare function signInboxCoSign(opts: { messageId: string; recipientDid: string; ciphertextB64: string; nonceB64: string; signingKey: Uint8Array; }): string; /** Verify a recipient's co-sign over the canonical release bytes. */ export declare function verifyInboxCoSign(opts: { messageId: string; recipientDid: string; ciphertextB64: string; nonceB64: string; signatureB64: string; publicKey: InboxVerifyingKey; }): boolean; /** Status options the API accepts on PATCH /v1/inbox/:id. */ export type InboxStatus = "unread" | "read" | "archived" | "spam" | "deleted"; /** * Client for `/v1/inbox`. Three layers of helpers: * * 1. **High-level**: `send(opts)` — encrypts, signs, optionally looks up * the recipient pubkey, posts. `decrypt(message, {recipientBoxPriv})` * unseals. Most callers use these. * 2. **Crypto-only**: `sealForRecipient`, `unsealForSelf`, * `signInboxEnvelope`, `signInboxCoSign` — for callers wiring custom * flows or testing wire interop. * 3. **Raw HTTP**: `sendCipher`, `list`, `get`, `thread`, `cosign`, * `patch`, `delete` — the underlying endpoints if you've assembled * the envelope yourself. */ export declare class InboxClient { private readonly http; /** @internal */ constructor(http: HttpConfig); /** Look up the recipient's active X25519 box key by DID. */ lookup(did: string): Promise; /** Encrypt + sign + POST in one call. Looks up the recipient's box key * if `recipientBoxPub` / `recipientBoxKeyId` are not supplied. */ send(opts: InboxSendOpts): Promise; /** Raw POST for callers who already have ciphertext + signature. */ sendCipher(body: Record): Promise; /** List inbox messages. Server filters by project (recipient side). */ list(opts?: { status?: string; identity_id?: string; limit?: number; }): Promise<{ messages: InboxMessage[]; count: number; note?: string; }>; get(id: string): Promise; /** Walk a thread by `in_reply_to` lineage, scoped to this project. */ thread(id: string): Promise<{ messages: InboxMessage[]; count: number; note?: string; }>; /** Release a message from `pending_dual_witness` by adding the * recipient's signature over the canonical cosign bytes. */ cosign(messageId: string, opts: InboxCoSignOpts): Promise; /** Update the message's status (unread / read / archived / spam / deleted). */ patch(id: string, status: InboxStatus): Promise; /** Soft-delete (status='deleted'). */ delete(id: string): Promise<{ id: string; deleted: true; }>; /** * Unseal a message for the recipient's local pair. * * Supply `senderPublicKey` — the reader's own record of the sender's * ed25519 key — to fail closed: an envelope that key cannot prove throws * before any plaintext is returned. Without it the call only proves * confidentiality, never authorship, so `sender_did` remains a * server-reported claim. */ decrypt(message: InboxMessage, opts: { recipientBoxPriv: Uint8Array; senderPublicKey?: InboxVerifyingKey; recipientDidAliases?: readonly string[]; }): Promise; /** * Stream inbox SSE frames, including protocol controls, and decrypt arrival * bodies locally. * * Unlike the earlier experimental helper, this iterator does not discard * `rejected`, `disconnect`, `refresh`, or catch-up frames. A replay larger * than the server page is delivered as `catchup-truncated`; reconnect with * `data.resume.since` and `data.resume.since_id`. The server closes that * partial stream instead of entering live mode. * * Box-key rotation is resolved by `recipient_box_key_id`: provide a map of * historical private keys or an async resolver. `recipientBoxPriv` remains * a convenience fallback for identities that have only one box key. * * Every arrival reports `sender_verified`. Supply `senderSigningKeys` or * `resolveSenderSigningKey` to prove attribution locally; without them the * body still decrypts and arrives marked unverified, since decryption alone * proves nothing about who sent it. `requireVerifiedSender` withholds the * plaintext of anything the caller's keys cannot prove. * * Breaking a `for await` loop invokes the generator's `finally`, cancels * the ReadableStream, and aborts the underlying fetch. */ voice(opts: InboxVoiceOpts): AsyncIterableIterator; private req; }