Encryption Model
NOOSChain stores encrypted project records without putting plaintext on-chain. The ADD_ENCRYPTED_RECORD consensus payload carries encryption metadata, recipient key envelopes or bucket-key references, public indexes, creator identifiers, and payload_hash. Ciphertext bytes are availability-layer data and are omitted from the canonical signed transaction envelope.
For the complete inventory of user keys, node keys, record DEKs, bucket DEKs, and key envelopes, see Key Model.
Payload Encryption
NOOSChain supports per_record_key and per_bucket_key bucket encryption modes.
In per_record_key mode, each encrypted record uses a fresh random 32-byte data encryption key, or DEK. The plaintext payload is encrypted with AES-256-GCM and a fresh random IV. AES-GCM provides confidentiality and authentication for the encrypted payload bytes.
In per_bucket_key mode, the bucket has an active encrypted bucket DEK reference in bucket_keys. Records are encrypted with the active bucket DEK and store bucket_key_id / bucket_key_version. Record-level key envelopes are normally empty because decryptors first unwrap the bucket key envelope and then decrypt the record payload.
The plaintext is never persisted by NOOSChain. In the default prepared ADD_ENCRYPTED_RECORD API flow, the API receives plaintext, encrypts it in memory, and returns an exact unsigned transaction envelope for the user to sign. Production deployments that do not want application memory to see plaintext or DEKs should move encryption into a KMS/HSM or enclave-backed service.
Payload Hash
payload_hash is SHA-256 over the plaintext payload bytes. It does not hash ciphertext, encryption metadata, IVs, auth tags, or key envelopes.
This is intentional: encryption is randomized, so the same plaintext can produce different ciphertext. The hash anchors plaintext integrity deterministically while allowing ciphertext availability and encryption details to evolve outside consensus state.
Key Envelopes
NOOSChain encrypts the DEK separately for each recipient. The current implementation uses libsodium sealed boxes. Because NOOSChain identities currently use Ed25519 keys, the key-envelope adapter converts Ed25519 public/private keys to Curve25519 only inside src/crypto/key-envelopes.ts.
Envelope fields include recipient type, recipient id, recipient public key, encrypted DEK, and algorithm name. Organizations currently reuse their organization public key directly. Future versions can replace this with organization HSM keys, key-rotation records, threshold decryption, or policy-managed envelope sets.
Envelope recipient ids and recipient public keys are visible to callers that can read envelope metadata. The encrypted DEK is not useful without the matching private key, but the recipient list itself is not hidden.
Bucket-key envelope recipients are validated against bucket replication strategy and access rules when CREATE_BUCKET_KEY or ROTATE_BUCKET_KEY executes. User envelopes require owner/encrypted-read/admin access. Organization envelopes used for node custody follow the bucket replication policy: metadata_only rejects organization key sharing, replicate_encrypted_to_authorized_nodes allows only creator or encrypted-read/admin organizations, and replicate_encrypted_to_all_nodes allows organizations with active registered nodes, but payload and bucket-key retrieval still requires a valid organization-signed node membership proof. A raw node public key or configured organization id is not enough.
Bucket-key rotation creates a new active key version. Existing records stay pinned to the old version and are not re-encrypted automatically. Plaintext bucket DEKs are never stored in PostgreSQL; prepared per-bucket writes unwrap/use them in memory through configured local unwrap material or a future KMS/HSM hook.
TODO: implement historical bucket-key rekey/re-encryption. That future flow should be separate from simple rotation, because re-encrypting existing payloads changes availability-layer ciphertext and needs explicit authorization, audit records, and careful handling of plaintext in client-side or KMS/HSM-controlled memory.
Consensus Boundary
encrypted_payload is availability-layer data and must never participate in consensus state hashing. Encrypted record state roots include payload_hash, encryption metadata, key envelopes or bucket-key references, public indexes, creator identifiers, and transaction hash, but not ciphertext bytes.
Replay verification remains deterministic even if future nodes do not store every ciphertext. Replay validates record state and payload commitments, not plaintext availability.
APIs
POST /buckets/:id/records/prepare-add-encrypted-record is the default preparation endpoint for encrypted-record writes. It requires an actor with bucket:write, checks that the configured local node is eligible under the bucket replication policy when a local node id is configured, resolves creator ids from the actor, receives plaintext, encrypts it according to the bucket mode, stores a short-lived exact-envelope hash, and returns the unsigned ADD_ENCRYPTED_RECORD envelope the user must sign. The response does not include raw DEKs or bucket-key envelopes used for write preparation.
POST /transactions requires preparedTransactionId for public ADD_ENCRYPTED_RECORD submissions. The node verifies the signature and recomputes the full prepared-envelope hash, including availability-layer encryptedPayload, before admitting the transaction. Altered, expired, reused, or unprepared encrypted-record submissions are rejected.
GET /buckets/:id/records/encrypted-prepare is a legacy metadata helper. It no longer returns bucket-key envelopes for per_bucket_key buckets and is not used by the default SDK plaintext write path.
POST /buckets/:id/records/encrypted-wrap-dek is a legacy per-record helper. The default SDK plaintext write path no longer uses it; the API prepares both per-record and per-bucket encrypted-record envelopes itself.
The TypeScript SDK uses the prepared endpoint from client.transactions.addEncryptedRecordFromPlaintext(...) and client.transactions.submitAddEncryptedRecordFromPlaintext(...). The SDK sends plaintext to the API, signs the exact prepared ADD_ENCRYPTED_RECORD envelope, and submits it through /transactions with the preparedTransactionId. User private keys do not reach the node; plaintext does reach the API in this flow.
Production deployments should use a KMS/HSM/enclave-backed prepare implementation if plaintext should not be visible to ordinary API application memory.
GET /buckets/:id/records/:recordId returns metadata, payload hash, encryption metadata, key envelopes, and public indexes to actors with bucket:read_metadata. It includes encrypted payload bytes only when the actor also has bucket:read_encrypted.
Synced nodes may legitimately store encrypted_payload = null when a bucket's replication policy says ciphertext should not be local. The canonical transaction payload remains unchanged for block hash verification; only the local materialized record omits ciphertext. payload_available_locally reports that local availability.
Nodes that later gain bucket:read_encrypted can run payload backfill to fetch missing ciphertext from peers when the bucket replication strategy allows it. For per_bucket_key records, authorized replication responses may also include the bucket-key envelope metadata for the referenced key version. Backfill updates only local availability columns and never changes consensus state.
Snapshots follow the same boundary. consensus_state_only snapshots omit encrypted payload bytes by default. include_local_ciphertext snapshots may include ciphertext already available locally, but snapshots never include plaintext payloads or raw DEKs.
POST /crypto/decrypt is DEV ONLY and disabled in production. It decrypts in memory and never persists plaintext. Production clients should decrypt locally.