nx_p256_ecdh.nx source
↩ module page · 144 lines · 5560 B
1// nx_p256_ecdh.nx -- ECDH key exchange on P-256 (secp256r1) for the
2// TLS 1.3 key_share path (browser arc rung B4-P256-KEYSHARE).
3//
4// The P-256 stack (field, point, scalar_mul) shipped for ECDSA cert
5// VERIFICATION; this module composes the same primitives into the
6// ECDHE role so the ClientHello can carry a secp256r1 KeyShareEntry
7// next to the X25519 one. Servers that insist on P-256 (the AWS-
8// fronted class behind the B2-HTTPBIN-CH alert) then never need a
9// HelloRetryRequest: they just pick our P-256 share.
10//
11// Public API:
12// p256_ecdh_derive_priv(seed32, out_priv32) -> verdict
13// Domain-separated ephemeral: SHA-256("NISHI-TLS13-P256-ECDH-V1"
14// || seed32), reduced mod n, forced nonzero. Lets the session
15// grow a P-256 keypair from the caller's existing 32 random
16// bytes without changing the session-new entropy contract.
17// p256_ecdh_pub(priv32, out_pub65) -> verdict
18// out = 0x04 || X || Y (RFC 8446 wire form for secp256r1).
19// p256_ecdh_shared(priv32, peer_pub65, peer_len, out_shared32) -> verdict
20// Validates peer point (format, on-curve, not-infinity) then
21// writes the x-coordinate of priv * peer as the 32-byte shared
22// secret (RFC 8446 section 7.4.2).
23//
24// Variable-time scalar_mul note: the scalar here is an EPHEMERAL
25// per-session secret, unlike ECDSA verify's public scalars. The
26// shipped double-and-add is variable-time; constant-time ladder is
27// a named follow-up rung (same lane as the x25519 ladder), accepted
28// for B4 because the scalar lives for one handshake.
29//
30// KAT: RFC 5903 section 8.1 vectors in nx_p256_keyshare_test.nx
31// (vectors re-read from rfc-editor.org 2026-06-10, not from memory).
32//
33// license_tier: INDEPENDENT_REDERIVE
34// genealogy_id: international-research-sources/ietf/rfc_5903 + ietf/rfc_8446
35// lineage_id: nishi_p256_ecdh_q10
36
37import "nx_syscalls.nx"
38import "nx_sha256.nx"
39import "nx_u256.nx"
40import "nx_p256_field.nx"
41import "nx_p256_point.nx"
42import "nx_p256_point_add.nx"
43import "nx_p256_scalar_mul.nx"
44import "nx_p256_modn.nx"
45
46const NX_P256_ECDH_OK: i64 = 1
47const NX_P256_ECDH_BAD_PRIV: i64 = 2 // scalar zero or >= n
48const NX_P256_ECDH_BAD_POINT: i64 = 3 // format/on-curve/infinity reject
49const NX_P256_ECDH_VERDICT_N: i64 = 4
50
51func nx_p256_ecdh_verdict_is_valid(v: i64) -> i64 {
52 if v < NX_P256_ECDH_OK { return 0 }
53 if v >= NX_P256_ECDH_VERDICT_N { return 0 }
54 return 1
55}
56
57// Load priv32 (big-endian) into limbs and range-check 0 < k < n.
58// Returns OK / BAD_PRIV; on OK the limbs are in out_k.
59func _p256_ecdh_load_scalar(priv32: *u8, out_k: *i64) -> i64 {
60 u256_load_be(out_k, priv32)
61 if u256_is_zero(out_k) == 1 { return NX_P256_ECDH_BAD_PRIV }
62 let n: *i64 = u256_alloc()
63 p256_modn_load_n(n)
64 if u256_cmp(out_k, n) >= 0 { return NX_P256_ECDH_BAD_PRIV }
65 return NX_P256_ECDH_OK
66}
67
68// Ephemeral P-256 private key from 32 seed bytes via domain-
69// separated SHA-256, conditionally reduced mod n (one subtract
70// suffices: n > 2^255 so any 256-bit digest is < 2n), forced
71// nonzero (probability 2^-256 branch, kept for totality).
72func p256_ecdh_derive_priv(seed32: *u8, out_priv32: *u8) -> i64 {
73 let tag: *u8 = "NISHI-TLS13-P256-ECDH-V1\x00" as *u8
74 let buf: *u8 = sys_mmap(64)
75 var i: i64 = 0
76 while i < 24 { buf[i] = tag[i]; i = i + 1 }
77 i = 0
78 while i < 32 { buf[24 + i] = seed32[i]; i = i + 1 }
79 let digest: *u8 = sys_mmap(32)
80 sha256_digest(buf, 56, digest)
81
82 let k: *i64 = u256_alloc()
83 u256_load_be(k, digest)
84 let n: *i64 = u256_alloc()
85 p256_modn_load_n(n)
86 if u256_cmp(k, n) >= 0 { u256_sub_with_borrow(k, k, n) }
87 if u256_is_zero(k) == 1 { k[0] = 1 }
88 u256_store_be(out_priv32, k)
89 return NX_P256_ECDH_OK
90}
91
92// out_pub65 = 0x04 || X(32) || Y(32) for priv * G.
93func p256_ecdh_pub(priv32: *u8, out_pub65: *u8) -> i64 {
94 let k: *i64 = u256_alloc()
95 let kv: i64 = _p256_ecdh_load_scalar(priv32, k)
96 if kv != NX_P256_ECDH_OK { return kv }
97
98 let G: *P256Point = p256_point_alloc()
99 p256_point_load_g(G)
100 let R: *P256Point = p256_point_alloc()
101 p256_scalar_mul(R, k, G)
102 if p256_point_is_infinity(R) == 1 { return NX_P256_ECDH_BAD_PRIV }
103 p256_point_to_affine(R)
104
105 out_pub65[0] = 4 as u8
106 u256_store_be(out_pub65 + 1, R.x)
107 u256_store_be(out_pub65 + 33, R.y)
108 return NX_P256_ECDH_OK
109}
110
111// Shared secret = x-coordinate of priv * peer_pub.
112// peer_pub65 MUST be a 65-byte uncompressed point (0x04 || X || Y);
113// the point is checked on-curve before any scalar work (boundary
114// validation: the peer share arrives off the wire).
115func p256_ecdh_shared(
116 priv32: *u8,
117 peer_pub65: *u8, peer_len: i64,
118 out_shared32: *u8
119) -> i64 {
120 if peer_len != 65 { return NX_P256_ECDH_BAD_POINT }
121 if peer_pub65[0] != (4 as u8) { return NX_P256_ECDH_BAD_POINT }
122
123 let px: *i64 = u256_alloc()
124 let py: *i64 = u256_alloc()
125 u256_load_be(px, peer_pub65 + 1)
126 u256_load_be(py, peer_pub65 + 33)
127
128 let P: *P256Point = p256_point_alloc()
129 p256_point_set_affine(P, px, py)
130 if p256_point_on_curve(P) != 1 { return NX_P256_ECDH_BAD_POINT }
131 if p256_point_is_infinity(P) == 1 { return NX_P256_ECDH_BAD_POINT }
132
133 let k: *i64 = u256_alloc()
134 let kv: i64 = _p256_ecdh_load_scalar(priv32, k)
135 if kv != NX_P256_ECDH_OK { return kv }
136
137 let R: *P256Point = p256_point_alloc()
138 p256_scalar_mul(R, k, P)
139 if p256_point_is_infinity(R) == 1 { return NX_P256_ECDH_BAD_POINT }
140 p256_point_to_affine(R)
141
142 u256_store_be(out_shared32, R.x)
143 return NX_P256_ECDH_OK
144}