carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// The device handoff: PROTOCOL.md's rung 1, and the only route in CONTRA that
2// carries a secret from one device to another.
3//
4// It carries it sealed. The waiting device mints an ephemeral P-256 keypair
5// and posts only the point; an enrolled device seals the Account Key to that
6// point with ECIES; the server stores an envelope it has no key for and hands
7// it over once. The AAD inside names the waiting device's own public key, so
8// even a server that swapped one envelope for another cannot make a second
9// device open it.
10//
11// # Why every route here needs a session
12//
13// slopbox's equivalent leaves /start and /claim open, because its waiting
14// device has no account yet. CONTRA's does: a fresh browser signs in with the
15// email and passphrase alone — the login key comes from S_pw, never from the
16// Account Key — and only then finds it cannot unlock. So both ends are
17// already authenticated, and scoping every query to the session's account
18// means a stranger cannot see that a code exists, let alone spend one.
19//
20// That is what buys the short code. Its job is to tell the person which
21// device they are approving, not to prove who is asking; the session already
22// did that.
23
24package vault
25
26import (
27 "encoding/hex"
28 "errors"
29 "net/http"
30 "time"
31
32 "crypto/sha256"
33
34 "github.com/carlosframework/contra/internal/contra/crypto"
35 "github.com/carlosframework/contra/internal/contra/store"
36)
37
38// codeHashOf decodes the hash the client sends.
39//
40// The client hashes; the server never receives the code at all. slopbox posts
41// the code and hashes it server-side, which stores nothing but does mean the
42// value passes through the server's memory and any request log in front of
43// it. Hashing on the client costs one line and makes "the code exists only on
44// the two screens the person is looking at" literally true.
45//
46// Nothing is lost by it: whoever can compute the hash already knows the code.
47func codeHashOf(hexHash string) ([]byte, bool) {
48 h, err := hex.DecodeString(hexHash)
49 if err != nil || len(h) != sha256.Size {
50 return nil, false
51 }
52 return h, true
53}
54
55// StartLink is the waiting device saying "here is my public key, here is the
56// hash of the code I am showing the person".
57//
58// The server does not choose the code. A server-chosen code would be a value
59// it knows in full for every live handoff; this way the only copies are on
60// the two screens a person is looking at.
61func (v *Vault) StartLink(w http.ResponseWriter, r *http.Request) {
62 acct, ok := accountID(r)
63 if !ok {
64 notFound(w)
65 return
66 }
67 var req struct {
68 CodeHashHex string `json:"code_hash_hex"`
69 DevicePublicHex string `json:"device_public_hex"`
70 }
71 if !readJSON(w, r, &req) {
72 return
73 }
74 codeHash, ok := codeHashOf(req.CodeHashHex)
75 if !ok {
76 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed code"})
77 return
78 }
79 devicePublic, err := hex.DecodeString(req.DevicePublicHex)
80 if err != nil || len(devicePublic) != crypto.PublicKeyLen {
81 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed device key"})
82 return
83 }
84 id, err := store.NewLinkID()
85 if err != nil {
86 writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start"})
87 return
88 }
89
90 link, err := v.store.StartLink(r.Context(), id, codeHash, acct, devicePublic, time.Now().UTC())
91 if errors.Is(err, store.ErrTooManyLinks) {
92 writeJSON(w, http.StatusTooManyRequests,
93 map[string]string{"error": "too many handoffs open — finish or cancel one first"})
94 return
95 }
96 if err != nil {
97 // A duplicate code hash lands here too. The client draws again.
98 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not start"})
99 return
100 }
101 writeJSON(w, http.StatusOK, map[string]any{
102 "expires_at": link.ExpiresAt.Format(time.RFC3339),
103 })
104}
105
106// PendingLink is the enrolled device asking what a typed code refers to. It
107// answers with the waiting device's public key and nothing else — there is
108// nothing else in the row.
109func (v *Vault) PendingLink(w http.ResponseWriter, r *http.Request) {
110 acct, ok := accountID(r)
111 if !ok {
112 notFound(w)
113 return
114 }
115 codeHash, ok := codeHashOf(r.URL.Query().Get("h"))
116 if !ok {
117 notFound(w)
118 return
119 }
120 link, err := v.store.PendingLink(r.Context(), acct, codeHash, time.Now().UTC())
121 if err != nil {
122 notFound(w)
123 return
124 }
125 writeJSON(w, http.StatusOK, map[string]any{
126 "device_public_hex": hex.EncodeToString(link.DevicePublic),
127 "approved": len(link.Sealed) > 0,
128 })
129}
130
131// ApproveLink takes the sealed envelope from an enrolled device.
132//
133// The envelope is opaque here and stays opaque. What this handler checks is
134// that it is long enough to be one at all — a caller that posted an empty
135// body would otherwise spend a live code on nothing, and the waiting device
136// would sit there until the code expired with no way to tell why.
137func (v *Vault) ApproveLink(w http.ResponseWriter, r *http.Request) {
138 acct, ok := accountID(r)
139 if !ok {
140 notFound(w)
141 return
142 }
143 var req struct {
144 CodeHashHex string `json:"code_hash_hex"`
145 SealedHex string `json:"sealed_hex"`
146 }
147 if !readJSON(w, r, &req) {
148 return
149 }
150 codeHash, ok := codeHashOf(req.CodeHashHex)
151 if !ok {
152 notFound(w)
153 return
154 }
155 sealed, err := hex.DecodeString(req.SealedHex)
156 if err != nil {
157 writeJSON(w, http.StatusBadRequest, map[string]string{"error": "malformed envelope"})
158 return
159 }
160 if err := v.store.ApproveLink(r.Context(), acct, codeHash, sealed, time.Now().UTC()); err != nil {
161 notFound(w)
162 return
163 }
164 w.WriteHeader(http.StatusNoContent)
165}
166
167// ClaimLink is the waiting device polling. Once there is an envelope it is
168// handed over and the row is deleted in the same transaction, so it leaves
169// exactly once.
170func (v *Vault) ClaimLink(w http.ResponseWriter, r *http.Request) {
171 acct, ok := accountID(r)
172 if !ok {
173 notFound(w)
174 return
175 }
176 codeHash, ok := codeHashOf(r.URL.Query().Get("h"))
177 if !ok {
178 notFound(w)
179 return
180 }
181 sealed, err := v.store.ClaimLink(r.Context(), acct, codeHash, time.Now().UTC())
182 if err != nil {
183 notFound(w)
184 return
185 }
186 if len(sealed) == 0 {
187 writeJSON(w, http.StatusOK, map[string]any{"approved": false})
188 return
189 }
190 writeJSON(w, http.StatusOK, map[string]any{
191 "approved": true,
192 "sealed_hex": hex.EncodeToString(sealed),
193 })
194}
195
196// CancelLink drops a code the waiting device gave up on, so closing the tab
197// does not leave a live handoff behind.
198func (v *Vault) CancelLink(w http.ResponseWriter, r *http.Request) {
199 acct, ok := accountID(r)
200 if !ok {
201 notFound(w)
202 return
203 }
204 var req struct {
205 CodeHashHex string `json:"code_hash_hex"`
206 }
207 if !readJSON(w, r, &req) {
208 return
209 }
210 codeHash, ok := codeHashOf(req.CodeHashHex)
211 if !ok {
212 notFound(w)
213 return
214 }
215 if err := v.store.CancelLink(r.Context(), acct, codeHash); err != nil {
216 notFound(w)
217 return
218 }
219 w.WriteHeader(http.StatusNoContent)
220}
221