aboutsummaryrefslogtreecommitdiff
path: root/source/crypto/SealedBox.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/crypto/SealedBox.mc')
-rw-r--r--source/crypto/SealedBox.mc89
1 files changed, 89 insertions, 0 deletions
diff --git a/source/crypto/SealedBox.mc b/source/crypto/SealedBox.mc
new file mode 100644
index 0000000..0804b96
--- /dev/null
+++ b/source/crypto/SealedBox.mc
@@ -0,0 +1,89 @@
+// Author: cirakg & claude ai
+// Summary: Authenticated encryption of records: AES-256-CBC plus HMAC-SHA256, encrypt-then-MAC (README 7.3).
+//
+// Format of a sealed box (one ByteArray):
+// version (1 byte, = 1) | IV (16 bytes) | AES-256-CBC ciphertext | tag (32 bytes)
+// tag = HMAC-SHA256(macKey, "OTPW-REC-1" || len(aad) as 4 bytes || aad || version || IV || ciphertext)
+//
+// * Two keys come from one master key with HKDF (labels "OTPW enc v1" and
+// "OTPW mac v1"), so encryption and authentication never share a key.
+// * aad ("additional authenticated data") is authenticated but not encrypted;
+// storage puts the record ID and header there (M3), so a record can't be
+// swapped for another one or have its header changed unnoticed. Its length
+// is part of the MAC input, so aad and ciphertext can't be shifted against
+// each other.
+// * open() checks the tag in constant time BEFORE decrypting, and returns
+// null for any problem (wrong key, tampering, truncation, unknown version)
+// without saying which.
+// * Every seal uses a fresh random IV from Cryptography.randomBytes.
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+module SealedBox {
+
+ const VERSION = 1;
+ const IV_BYTES = 16;
+ const TAG_BYTES = 32;
+ const KEY_BYTES = 32;
+ const MIN_SIZE = 1 + IV_BYTES + 16 + TAG_BYTES; // ciphertext is at least one block
+
+ // The encryption and MAC keys derived from one master key.
+ class Keys {
+ var encKey as ByteArray;
+ var macKey as ByteArray;
+
+ function initialize(masterKey as ByteArray) {
+ encKey = Kdf.hkdf(masterKey, []b, Bytes.fromUtf8("OTPW enc v1"), KEY_BYTES);
+ macKey = Kdf.hkdf(masterKey, []b, Bytes.fromUtf8("OTPW mac v1"), KEY_BYTES);
+ }
+
+ // Wipe both keys (when the session locks).
+ function wipe() as Void {
+ Bytes.wipe(encKey);
+ Bytes.wipe(macKey);
+ }
+ }
+
+ // Seal plaintext with a fresh random IV.
+ function seal(keys as Keys, aad as ByteArray, plaintext as ByteArray) as ByteArray {
+ return sealWithIv(keys, aad, plaintext, Cryptography.randomBytes(IV_BYTES));
+ }
+
+ // Seal with a given IV. Only tests call this directly (fixed IVs make the
+ // output reproducible); reusing an IV in real use would be a mistake.
+ function sealWithIv(keys as Keys, aad as ByteArray, plaintext as ByteArray, iv as ByteArray) as ByteArray {
+ var ciphertext = Aes.cbcEncrypt(keys.encKey, iv, plaintext);
+ var box = [VERSION]b;
+ box.addAll(iv);
+ box.addAll(ciphertext);
+ box.addAll(tag(keys, aad, box));
+ return box;
+ }
+
+ // The plaintext, or null if the box is malformed, was sealed with another
+ // key or aad, or was changed in any way.
+ function open(keys as Keys, aad as ByteArray, box as ByteArray) as ByteArray? {
+ var size = box.size();
+ if (size < MIN_SIZE || (size - 1 - IV_BYTES - TAG_BYTES) % Aes.BLOCK_BYTES != 0 || box[0] != VERSION) {
+ return null;
+ }
+ var body = box.slice(0, size - TAG_BYTES);
+ var expected = tag(keys, aad, body);
+ var valid = Bytes.equal(expected, box.slice(size - TAG_BYTES, size));
+ Bytes.wipe(expected);
+ if (!valid) {
+ return null;
+ }
+ return Aes.cbcDecrypt(keys.encKey, box.slice(1, 1 + IV_BYTES), box.slice(1 + IV_BYTES, size - TAG_BYTES));
+ }
+
+ // HMAC over the label, the aad (with its length) and the box body.
+ function tag(keys as Keys, aad as ByteArray, body as ByteArray) as ByteArray {
+ var input = Bytes.fromUtf8("OTPW-REC-1");
+ input.addAll(Bytes.uint32BigEndian(aad.size()));
+ input.addAll(aad);
+ input.addAll(body);
+ return Hmac.nativeSha256(keys.macKey, input);
+ }
+}