OSCORE (Object Security for Constrained RESTful Environments, RFC 8613): end-to-end application-layer security for CoAP, AES-CCM-16-64-128 + HKDF-SHA-256 only. See README.md for purpose and API. Provenance: see NOTICE.
Status: complete. The §3.2.1 info CBOR encoder, the §5.4
aad_array/Enc_structure CBOR encoders, the §6.1 compressed COSE option
codec, the §3.2.2 anti-replay sliding window, and the six crypto cores
(deriveKey, deriveContext, computeNonce, buildAad, protect,
unprotect) are all implemented — no @panic/TODO stub remains in
root.zig. See "The six crypto cores" below for what each does and how
it is anchored.
- Source of truth: RFC 8613's own text (fetched from
www.rfc-editor.org/rfc/rfc8613.txt) — no third-party OSCORE implementation was read or ported (seeNOTICE). Only the MANDATORY-to-implement ciphersuite is in scope: AES-CCM-16-64-128 (COSE algorithm 10) for the AEAD, HKDF-SHA-256 for §3.2.1 key derivation —Algorithmhas exactly one member. std.crypto.aead.aes_ccm.Aes128Ccm8IS the exact target AEAD:Aes128Ccm8 = AesCcm(Aes128, tag_len=8, nonce_len=13)— 128-bit key, 13-byte nonce, 8-byte tag, matching RFC 8152 §10.2's AES-CCM-16-64-128 definition field-for-field.std.crypto.kdf.hkdf.HkdfSha256supplies RFC 5869's two-phaseextract/expand, exactly what §3.2.1 specifies. Neither is a gap; the OSCORE-specific construction AROUND them is.- Security context split (§3.1):
CommonContext(algorithm + Common IV),SenderContext(own ID + key + Sender Sequence Number),RecipientContext(peer ID + key +ReplayWindow) — bundled asSecurityContext. Deliberately mutable, single-owner state (meta. concurrency = .single_owner, same shape asbolt8.Transport): the Sender Sequence Number increments on everyprotect, theReplayWindowslides on every acceptedunprotect. - CoAP-agnostic by design:
meta.deps = .{}— no build dependency on the siblingcoapmodule, andprotect/unprotectnever parse or build a CoAP message themselves. They operate on the §5.3 "plaintext" and §5.4 "options" as opaque caller-supplied byte strings. The intended integration iscoap(RFC 7252 message codec, already in this repository) assembling those byte strings and wiringoscorein as its object-security layer — deliberately NOT built in this pass, to keep the crypto core testable and reviewable in isolation. - Exchange tracking is the caller's job: matching a response back to
the request that generated it (needed for §5.2's "reuse the request's
nonce" majority case) is NOT this module's concern —
unprotect'srequest_nonce_sourceparameter takes whatever(id, Partial IV)the caller already tracked.oscoreitself has no notion of a CoAP Token or an in-flight exchange table.
<- nonce_length-6 bytes -> <-- 5 bytes -->
+---+-------------------+--------+---------+-----+
| S | zero padding | ID_PIV | zero pad| PIV |----+
+---+-------------------+--------+---------+-----+ |
|
<---------------- nonce_length -----------------> |
+------------------------------------------------+ |
| Common IV |->(XOR)
+------------------------------------------------+ |
|
<---------------- nonce_length -----------------> |
+------------------------------------------------+ |
| Nonce |<---+
+------------------------------------------------+
At this module's only algorithm (nonce_length = 13), the ID_PIV field is
nonce_length - 6 = 7 bytes wide (id_piv_field_width). S is a single
byte holding id_piv.len (NOT the field width — the actual ID length,
0-7). Both ID_PIV and the 5-byte PIV are LEFT-padded with zero bytes
(the real value right-aligned within its fixed-width field), then the
whole S || id_piv_padded || piv_padded block is XORed byte-for-byte
against the Common IV. Hand-verified against Appendix C.1 while writing
computeNonce's doc comment: the client's Sender ID is empty (S = 0,
an all-zero block), so its sender nonce equals the Common IV UNCHANGED
(0x4622d4dd6d944168eefb54987c both times); its recipient nonce (S = 1,
ID_PIV = 0x01) differs from the Common IV in exactly the bytes the XOR
of 0x01 00 00 00 00 00 00 01 00 00 00 00 00 against the Common IV
predicts — 0x4722d4dd6d944169eefb54987c, matching the RFC's published
value byte-for-byte.
AAD = Enc_structure = [ "Encrypt0", h'', external_aad ]
external_aad = bstr .cbor aad_array
aad_array = [ oscore_version, [ alg_aead ], request_kid, request_piv, options ]
encodeAadArray (REAL) builds aad_array's bytes directly. encodeEncStructure
(REAL, plain RFC 8152 COSE, not OSCORE-specific) wraps an already-serialized
byte string as Enc_structure's third array element — note that
external_aad's own CBOR encoding IS just "wrap these bytes in a bstr
header"; encodeEncStructure's external_aad parameter takes the RAW
aad_array bytes and does that wrapping itself, matching the RFC's own
"external_aad = bstr .cbor aad_array" notation. buildAad (STUB) is the
two-line composition of both. Every one of these three functions was
hand-verified against Appendix C.4's worked example while writing them:
aad_array = 0x8501810a40411440 (8 bytes: array(5), uint(1), array(1)
containing uint(10), then three bstrs of length 0/1/0), and the full
AAD = 0x8368456e63727970743040488501810a40411440 (20 bytes: array(3),
the 9-byte text(8)"Encrypt0", the 1-byte bstr(0) protected header, then
bstr(8) wrapping those same 8 aad_array bytes).
request_kid/request_piv are always the ORIGINAL REQUEST's own
values, even when building the AAD for a RESPONSE (§5.4's own text:
"request_kid: contains the value of the 'kid' in the COSE object of the
request"). Appendix C.7/C.8 (responses to the C.4 request) both use the
SAME aad_array/AAD bytes as C.4 itself, confirming this: the AAD never
reflects the response's own (absent, or freshly-minted in C.8's case)
Partial IV/kid.
ReplayWindow is a sliding bitmap (highest_seen: u64 + mask: u64),
the same algorithm family DTLS/IPsec anti-replay windows use (RFC 8613's
own §3.2.2 names RFC 6347 §4.1.2.6 as its default mechanism). It is
implemented for real (not stubbed) because it is pure integer/bit
bookkeeping over PUBLIC sequence numbers — the AEAD tag is what actually
authenticates a message; the window only decides whether to bother
re-verifying a sequence number that has already been accepted once. Two
invariants matter:
checkis read-only;updatemust only run AFTER a successful AEAD verification. Recording an unverified sequence number would let an on-path attacker "burn" a legitimate future window slot with a forged or replayed garbage message, causing the real message (arriving later, with that same sequence number) to be wrongly rejected as a replay.unprotect's own doc comment spells out the exact check-then-verify- then-update ordering (§8.2/§8.4).- Responses are never replay-checked (§8.4) — not even Appendix
C.8's response, which carries its own fresh Partial IV.
is_requestis an explicitunprotectparameter for exactly this reason; it is NOT inferred from whether the option carries a Partial IV (C.8 proves that inference would be wrong: it has a Partial IV in the option, yet is still a response, still never replay-checked).
window_size is bounded to 64 by the u64 bitmap backing — RFC 8613
does not mandate an exact size ("may be different in the two endpoints",
§3.2), and 64 comfortably covers the §3.2.2 stated default of 32.
- This module supplies no transport, no exchange tracking, and no CoAP
parsing. It is a pure crypto/codec core over caller-supplied byte
strings; a consumer (the sibling
coapmodule, or any other CoAP stack) is responsible for extracting the §5.3 plaintext and §5.4 options from a real CoAP message, tracking which response belongs to which request, and callingprotect/unprotectwith the right parameters. A caller that gets the request/response nonce-reuse decision wrong (§5.2) produces messages this module will happily protect/verify but that are NOT what RFC 8613 intends — this module cannot detect that misuse from the inside. - Sequence-number exhaustion:
protectMUST fail (error.SequenceNumberExhausted) rather than wrap the Sender Sequence Number pastmax_partial_iv(2^40 - 1) — reusing a(key, nonce)pair is a full AEAD break (RFC 8613 §7.2.1's own warning). A Security Context that hits this ceiling MUST be re-established, not patched around. - Never record an unverified sequence number as seen — see the replay
window section above; this is the single easiest correctness property
to get backwards when implementing
unprotect(check-before-decrypt is a valid optimization, but update-before-decrypt is a protocol bug). buildAad'srequest_kid/request_pivmisuse: passing the RESPONSE's own kid/Partial IV instead of the ORIGINAL REQUEST's intoAadParamswhen protecting/unprotecting a response silently produces an AAD the peer will never be able to reproduce — not a crash, a guaranteedAuthenticationFailedon the other end.protect/unprotect's own doc comments call this out explicitly.SenderContext.id/RecipientContext.idlength: MUST be<= id_piv_field_width(7 bytes at this algorithm) forcomputeNonceto accept it —error.IdTooLongotherwise. This is a deployment-level Sender/Recipient ID sizing constraint the RFC leaves to the application (§3.1: "Maximum length is determined by the AEAD Algorithm").- Constant-time:
deriveKey/protect/unprotecthandle secret key material (master_secret, the derived Sender/Recipient Key) and MUST route it only throughstd.crypto's own constant-time HMAC/AES-CCM implementations — no comparison or branch on key bytes anywhere in this module's own code once filled in.ReplayWindow/OscoreOption/the CBOR encoders handle only PUBLIC data (sequence numbers, IDs, option bytes) and have no constant-time obligation.
The six crypto cores in root.zig are all real — no @panic/TODO stub
remains. Each function's own doc comment spells out the exact RFC 8613
construction step-by-step:
deriveKey(§3.2.1) —encodeInfo(already real) intoHkdfSha256.extract/.expand.deriveContext(§3.2) — threederiveKeycalls (Sender Key, Recipient Key, Common IV — the last ALWAYS withid = &.{}) assembled into aSecurityContext.computeNonce(§5.2) — the XOR construction detailed above.buildAad(§5.4) —encodeAadArray(already real) wrapped viaencodeEncStructure(already real); a two-line composition kept as its own core per this module's task brief (byte-exact-gated independently ofprotect/unprotect, not just transitively).protect/unprotect(§8.1-§8.4) —computeNonce+buildAad+std.crypto.aead.aes_ccm.Aes128Ccm8.encrypt/.decrypt, plus (unprotectonly) theReplayWindowcheck-then-verify-then- update ordering.
Byte-exact oracle for all six: RFC 8613 Appendix C's official vectors
(kat_vectors.zig), exercised by kat_test.zig — every C.1-C.3
key-derivation output (both directions), every C.4-C.8 nonce/AAD/
ciphertext/option value, plus a tamper-rejection test, a replay-rejection
test, and an end-to-end round trip with fresh (non-published) key
material.
zig build test-oscoreand-Doptimize=ReleaseFastboth go green;zig fmt --check modules/oscore/clean.
Anchor grade: class A · oracle EXTERNAL
- Class A — wire/interop format — other implementations must byte-agree with it.
- Oracle EXTERNAL — published vectors, goldens captured from a foreign implementation, or a test run against a live foreign peer.
What the tests actually contain. RFC 8613 Appendix C official vectors (key derivation + protected-message C.4-C.8)