openvibers/openvibe.link Commits

Add Node blind fill handling (#142)

OP OpenVibers committed 4 days ago · c908441a9e · 10 files, +788 −5

* Add Node blind fill handling

* Use sealed origin in machine fill command

* Harden blind fill execution and replay handling

* Blind fill: the machine refuses a grant it has already opened

Opus review: Run makes a grant single-use, but Run is the party that must not be able to reuse one. The Node now
records each request id the first time it opens it (kept 2 minutes, twice the grant's life) and refuses a repeat.

Changes

ddiff --git a/internal/lockeropen/open.go b/internal/lockeropen/open.go
nnew file mode 100644
iindex 0000000..15663da
---- /dev/null
++++ b/internal/lockeropen/open.go
@@@ -0,0 +1,248 @@
++// Package lockeropen opens the people lock emitted by openvibe-sdk/locker for Link Send recipients.
++// Keep the byte format and vectors aligned with OpenVibe.SDK's locker implementation.
++package lockeropen
++
++import (
++ "crypto/aes"
++ "crypto/cipher"
++ "crypto/ecdh"
++ "crypto/elliptic"
++ "crypto/hkdf"
++ "crypto/sha256"
++ "encoding/base64"
++ "encoding/binary"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "math"
++ "math/big"
++ "strings"
++)
++
++const lockerVersion = "locker.envelope.v1"
++const suite = "AES-256-GCM"
++const chunkSize = 1 << 20
++const maxBytes = 8 << 20
++
++type JWK struct {
++ Kty string `json:"kty"`
++ Crv string `json:"crv"`
++ X string `json:"x"`
++ Y string `json:"y"`
++ D string `json:"d"`
++}
++type Subject struct {
++ Type string `json:"type"`
++ ID string `json:"id"`
++}
++type peopleSlot struct {
++ Subject Subject `json:"subject"`
++ Algorithm string `json:"algorithm"`
++ RecipientKey string `json:"recipient_key"`
++ Ephemeral JWK `json:"ephemeral_public_key"`
++ Salt string `json:"salt"`
++ Nonce string `json:"nonce"`
++ Wrapped string `json:"wrapped_key"`
++}
++type header struct {
++ Version string `json:"version"`
++ Suite string `json:"suite"`
++ ChunkSize int `json:"chunk_size"`
++ PaddedSize int `json:"padded_size"`
++ NonceBase string `json:"nonce_base"`
++ Lock string `json:"lock"`
++ Slots struct {
++ People []peopleSlot `json:"people"`
++ } `json:"slots"`
++}
++
++// Open validates and opens an SDK Locker people envelope addressed to the Node's Link Send key.
++func Open(envelope []byte, private JWK, recipient Subject) ([]byte, error) {
++ if len(envelope) < 8 || len(envelope) > maxBytes || string(envelope[:4]) != "OVLK" {
++ return nil, errors.New("invalid locker envelope")
++ }
++ headerLen := int(binary.BigEndian.Uint32(envelope[4:8]))
++ if headerLen < 2 || headerLen > 65536 || 8+headerLen > len(envelope) {
++ return nil, errors.New("invalid locker header")
++ }
++ var h header
++ if json.Unmarshal(envelope[8:8+headerLen], &h) != nil {
++ return nil, errors.New("invalid locker header")
++ }
++ if h.Version != lockerVersion || h.Suite != suite || h.ChunkSize != chunkSize || h.Lock != "people" || h.PaddedSize < 256 || h.PaddedSize > maxBytes {
++ return nil, errors.New("unsupported locker envelope")
++ }
++ contentKey, err := unwrap(h.Slots.People, private, recipient)
++ if err != nil {
++ return nil, err
++ }
++ defer wipe(contentKey)
++ block, err := aes.NewCipher(contentKey)
++ if err != nil {
++ return nil, errors.New("invalid locker key")
++ }
++ gcm, err := cipher.NewGCM(block)
++ if err != nil {
++ return nil, errors.New("invalid locker cipher")
++ }
++ core := map[string]any{"version": h.Version, "suite": h.Suite, "chunk_size": h.ChunkSize, "padded_size": h.PaddedSize, "nonce_base": h.NonceBase, "lock": h.Lock}
++ aadCore, _ := json.Marshal(core)
++ nonceBase, err := base64.StdEncoding.DecodeString(h.NonceBase)
++ if err != nil || len(nonceBase) != 8 {
++ return nil, errors.New("invalid locker nonce")
++ }
++ data, offset := envelope, 8+headerLen
++ counter := uint32(0)
++ expectedChunks := (h.PaddedSize + chunkSize - 1) / chunkSize
++ plain := make([]byte, 0, h.PaddedSize)
++ defer wipe(plain)
++ var expected int64 = -1
++ finalSeen := false
++ for offset < len(data) {
++ if int(counter) >= expectedChunks {
++ return nil, errors.New("too many locker chunks")
++ }
++ if offset+17 > len(data) {
++ return nil, errors.New("truncated locker chunk")
++ }
++ n := int(binary.BigEndian.Uint32(data[offset : offset+4]))
++ flags := data[offset+4]
++ nonce := data[offset+5 : offset+17]
++ offset += 17
++ if n < gcm.Overhead() || n > chunkSize+gcm.Overhead() || offset+n > len(data) {
++ return nil, errors.New("invalid locker chunk size")
++ }
++ wantNonce := make([]byte, 12)
++ copy(wantNonce, nonceBase)
++ binary.BigEndian.PutUint32(wantNonce[8:], counter)
++ isFinal := int(counter) == expectedChunks-1
++ if !equal(nonce, wantNonce) || flags&^byte(1) != 0 || (flags&1 == 1) != isFinal {
++ return nil, errors.New("invalid locker chunk metadata")
++ }
++ aad := make([]byte, len(aadCore)+5)
++ copy(aad, aadCore)
++ binary.BigEndian.PutUint32(aad[len(aadCore):], counter)
++ aad[len(aadCore)+4] = flags
++ part, e := gcm.Open(nil, nonce, data[offset:offset+n], aad)
++ if e != nil {
++ return nil, errors.New("locker envelope authentication failed")
++ }
++ offset += n
++ if counter == 0 {
++ if len(part) < 8 {
++ return nil, errors.New("invalid locker length")
++ }
++ v := binary.BigEndian.Uint64(part[:8])
++ if v > uint64(h.PaddedSize-8) || v > math.MaxInt32 {
++ wipe(part)
++ return nil, errors.New("invalid locker length")
++ }
++ expected = int64(v)
++ part = part[8:]
++ }
++ plain = append(plain, part...)
++ wipe(part)
++ counter++
++ if flags&1 != 0 {
++ finalSeen = true
++ if offset != len(data) {
++ return nil, errors.New("trailing locker data")
++ }
++ break
++ }
++ }
++ if !finalSeen || int(counter) != expectedChunks || expected < 0 || len(plain) != h.PaddedSize-8 {
++ return nil, errors.New("truncated locker content")
++ }
++ result := append([]byte(nil), plain[:expected]...)
++ return result, nil
++}
++
++func unwrap(slots []peopleSlot, key JWK, recipient Subject) ([]byte, error) {
++ if key.Kty != "EC" || key.Crv != "P-256" {
++ return nil, errors.New("invalid Link Send private key")
++ }
++ d, err := base64.RawURLEncoding.DecodeString(key.D)
++ if err != nil || len(d) != 32 {
++ return nil, errors.New("invalid Link Send private key")
++ }
++ priv, err := ecdh.P256().NewPrivateKey(d)
++ if err != nil {
++ return nil, errors.New("invalid Link Send private key")
++ }
++ pubJSON, _ := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": key.X, "y": key.Y})
++ thumb := sha256.Sum256(pubJSON)
++ recipientKey := base64.RawURLEncoding.EncodeToString(thumb[:])
++ for _, slot := range slots {
++ if slot.Subject != recipient || slot.RecipientKey != recipientKey || slot.Algorithm != "ECDH-P256/HKDF-SHA256/AES-256-GCM" {
++ continue
++ }
++ x, ex := base64.RawURLEncoding.DecodeString(slot.Ephemeral.X)
++ y, ey := base64.RawURLEncoding.DecodeString(slot.Ephemeral.Y)
++ if ex != nil || ey != nil || slot.Ephemeral.Kty != "EC" || slot.Ephemeral.Crv != "P-256" || len(x) != 32 || len(y) != 32 {
++ continue
++ }
++ point := elliptic.Marshal(elliptic.P256(), newInt(x), newInt(y))
++ pub, e := ecdh.P256().NewPublicKey(point)
++ if e != nil {
++ continue
++ }
++ shared, e := priv.ECDH(pub)
++ if e != nil {
++ continue
++ }
++ salt, e1 := base64.StdEncoding.DecodeString(slot.Salt)
++ nonce, e2 := base64.StdEncoding.DecodeString(slot.Nonce)
++ wrapped, e3 := base64.StdEncoding.DecodeString(slot.Wrapped)
++ if e1 != nil || e2 != nil || e3 != nil || len(nonce) != 12 || len(wrapped) != 48 {
++ wipe(shared)
++ continue
++ }
++ info := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s", recipient.Type, recipient.ID, recipientKey))
++ wrapKey, e := hkdf.Key(sha256.New, shared, salt, string(info), 32)
++ wipe(shared)
++ if e != nil {
++ continue
++ }
++ ephemeralJSON, _ := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": slot.Ephemeral.X, "y": slot.Ephemeral.Y})
++ aad := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s|%s|%s", recipient.Type, recipient.ID, recipientKey, ephemeralJSON, base64.StdEncoding.EncodeToString(salt)))
++ block, _ := aes.NewCipher(wrapKey)
++ gcm, _ := cipher.NewGCM(block)
++ content, e := gcm.Open(nil, nonce, wrapped, aad)
++ wipe(wrapKey)
++ if e == nil && len(content) == 32 {
++ return content, nil
++ }
++ wipe(content)
++ }
++ return nil, errors.New("locker envelope is not addressed to this machine")
++}
++
++func newInt(b []byte) *big.Int { return new(big.Int).SetBytes(b) }
++func equal(a, b []byte) bool {
++ if len(a) != len(b) {
++ return false
++ }
++ var x byte
++ for i := range a {
++ x |= a[i] ^ b[i]
++ }
++ return x == 0
++}
++func wipe(b []byte) {
++ for i := range b {
++ b[i] = 0
++ }
++}
++
++// DecodeEnvelope accepts the base64 form sent in the JSON grant.
++func DecodeEnvelope(s string) ([]byte, error) {
++ if strings.ContainsAny(s, "\r\n\t ") || len(s) > maxBytes*2 {
++ return nil, errors.New("invalid locker envelope encoding")
++ }
++ b, err := base64.StdEncoding.DecodeString(s)
++ if err != nil || len(b) > maxBytes {
++ return nil, errors.New("invalid locker envelope encoding")
++ }
++ return b, nil
++}
ddiff --git a/internal/lockeropen/open_test.go b/internal/lockeropen/open_test.go
nnew file mode 100644
iindex 0000000..87ad9bb
---- /dev/null
++++ b/internal/lockeropen/open_test.go
@@@ -0,0 +1,76 @@
++package lockeropen
++
++import (
++ "encoding/base64"
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "testing"
++)
++
++func TestOpenSDKLinkSendVector(t *testing.T) {
++ b, err := os.ReadFile(filepath.Join("testdata", "sdk-link-send-v1.json"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ var v struct {
++ Recipient Subject `json:"recipient"`
++ Private JWK `json:"private_jwk"`
++ Envelope string `json:"envelope_base64"`
++ Expected any `json:"expected"`
++ }
++ if err := json.Unmarshal(b, &v); err != nil {
++ t.Fatal(err)
++ }
++ envelope, err := base64.StdEncoding.DecodeString(v.Envelope)
++ if err != nil {
++ t.Fatal(err)
++ }
++ plain, err := Open(envelope, v.Private, v.Recipient)
++ if err != nil {
++ t.Fatal(err)
++ }
++ var got any
++ if err := json.Unmarshal(plain, &got); err != nil {
++ t.Fatal(err)
++ }
++ if !jsonEqual(got, v.Expected) {
++ t.Fatalf("opened payload differs from SDK vector")
++ }
++ wipe(plain)
++}
++
++func TestOpenRejectsWrongKeyAndTampering(t *testing.T) {
++ b, err := os.ReadFile(filepath.Join("testdata", "sdk-link-send-v1.json"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ var v struct {
++ Recipient Subject `json:"recipient"`
++ Private JWK `json:"private_jwk"`
++ Envelope string `json:"envelope_base64"`
++ }
++ if err := json.Unmarshal(b, &v); err != nil {
++ t.Fatal(err)
++ }
++ envelope, err := base64.StdEncoding.DecodeString(v.Envelope)
++ if err != nil {
++ t.Fatal(err)
++ }
++ wrong := v.Private
++ wrong.D = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
++ if _, err := Open(envelope, wrong, v.Recipient); err == nil {
++ t.Fatal("wrong private key opened envelope")
++ }
++ corrupt := append([]byte(nil), envelope...)
++ corrupt[len(corrupt)-1] ^= 1
++ if _, err := Open(corrupt, v.Private, v.Recipient); err == nil {
++ t.Fatal("tampered envelope opened")
++ }
++}
++
++func jsonEqual(a, b any) bool {
++ x, _ := json.Marshal(a)
++ y, _ := json.Marshal(b)
++ return string(x) == string(y)
++}
ddiff --git a/internal/lockeropen/testdata/sdk-link-send-v1.json b/internal/lockeropen/testdata/sdk-link-send-v1.json
nnew file mode 100644
iindex 0000000..f219cd8
---- /dev/null
++++ b/internal/lockeropen/testdata/sdk-link-send-v1.json
@@@ -0,0 +1,29 @@
++{
++ "format": "openvibe-locker-link-send-v1",
++ "recipient": {
++ "type": "node",
++ "id": "nod_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "private_jwk": {
++ "key_ops": [
++ "deriveBits"
++ ],
++ "ext": true,
++ "kty": "EC",
++ "x": "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",
++ "y": "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI",
++ "crv": "P-256",
++ "d": "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0"
++ },
++ "plaintext_json": "{\"request_id\":\"fr_01JAB2C3D4E5F6G7H8J9K0MNPQ\",\"origin\":\"https://example.org\",\"username\":\"vector-user\",\"password\":\"vector-password\",\"code\":\"123456\",\"submit\":true,\"exp\":1893456000000}",
++ "envelope_base64": "T1ZMSwAAAnJ7ImNodW5rX3NpemUiOjEwNDg1NzYsImxvY2siOiJwZW9wbGUiLCJub25jZV9iYXNlIjoieDZFalZrUDBHSUk9IiwicGFkZGVkX3NpemUiOjI1Niwic2xvdHMiOnsicGVvcGxlIjpbeyJhbGdvcml0aG0iOiJFQ0RILVAyNTYvSEtERi1TSEEyNTYvQUVTLTI1Ni1HQ00iLCJlcGhlbWVyYWxfcHVibGljX2tleSI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6InBObmdwVFNFM1gzQ19DR3pmcEF0ZW5qbGlMUEZaOWFyQlZOdEkwY3RQWHciLCJ5IjoiZlhaVDJKY0REaVhFYjZ4SDNMVXJxbFBha00zR1F3ZTNhcWJWXzF2aFZMSSJ9LCJub25jZSI6ImdkRHZ6MmI1aDdUM1NHS3EiLCJyZWNpcGllbnRfa2V5IjoiVENHaDJnSDZISXVUMC1BOUdlMGpPVHJGbVJYV1FrZEE2ZlpHOEp4aWtUQSIsInNhbHQiOiI1MXV5RmtBZkxmaUtQMEFjZWJ3eTdnPT0iLCJzdWJqZWN0Ijp7ImlkIjoibm9kXzAxSkFCMkMzRDRFNUY2RzdIOEo5SzBNTlBRIiwidHlwZSI6Im5vZGUifSwid3JhcHBlZF9rZXkiOiJYUy9wcDhtako5by90alpYOHNMcDBXcnA2RTVnTXNFeGFJYmJkalRBMXMxWXl5K290WFEzSFNtZDZGNWR1Mlp0In1dfSwic3VpdGUiOiJBRVMtMjU2LUdDTSIsInZlcnNpb24iOiJsb2NrZXIuZW52ZWxvcGUudjEifQAAARABx6EjVkP0GIIAAAAA69pYH8E0kbpjNaAex1w2c3LlQrxhh736ExhRmzg6br1BRsQhpfz6lnnj7HSwPHl9tokF8lFZ7B/StHSeKZjXlwIib6cK/v5ochfyGemSfYL5e7s9cNUjBJDWZ9SjuiZ9MGRdPJiHbP8BLvtToG4MrvRudpXHY5XfQKoZg1sNbSeW0HfrsGasGQcEjGdvrRUeYFDDBmTL1B5Dk9+X1eHRNTtiQJ5HWibwP+UocDmKrqLypSEsip1D8gjjuBkHgLy39yc0lvGcg14+4AD668aC8BgDHI/5AVL+FLCN7FceUKYuupW2x+B0PlsT9VQWy6EUuIcXd2s2Y/bP/p601ZkUi8WLrNmwl4hS7YT4pvPKJ70=",
++ "expected": {
++ "request_id": "fr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "origin": "https://example.org",
++ "username": "vector-user",
++ "password": "vector-password",
++ "code": "123456",
++ "submit": true,
++ "exp": 1893456000000
++ }
++}
ddiff --git a/internal/node/blindfill.go b/internal/node/blindfill.go
nnew file mode 100644
iindex 0000000..e69746c
---- /dev/null
++++ b/internal/node/blindfill.go
@@@ -0,0 +1,154 @@
++package node
++
++import (
++ "encoding/json"
++ "fmt"
++ "net/url"
++ "regexp"
++ "strings"
++ "sync"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/lockeropen"
++ "github.com/OpenVibers/OpenVibe.Node/internal/send"
++)
++
++type blindFillRequest struct {
++ Action string `json:"action"`
++ RequestID string `json:"request_id"`
++ Envelope string `json:"envelope"`
++}
++type blindFillGrant struct {
++ RequestID string `json:"request_id"`
++ Origin string `json:"origin"`
++ Username string `json:"username"`
++ Password string `json:"password"`
++ Code string `json:"code"`
++ Submit bool `json:"submit"`
++ Exp int64 `json:"exp"`
++}
++type blindFillPluginRequest struct {
++ Action string `json:"action"`
++ RequestID string `json:"request_id"`
++ Origin string `json:"origin"`
++ Username string `json:"username"`
++ Password string `json:"password"`
++ Code string `json:"code,omitempty"`
++ Submit bool `json:"submit"`
++}
++
++var totpDigits = regexp.MustCompile(`^[0-9]{6,8}$`)
++var exactFillRequestID = regexp.MustCompile(`^fr_[A-Za-z0-9_-]{16,64}$`)
++var exactHTTPSOrigin = regexp.MustCompile(`^https://[a-z0-9.-]+(:[0-9]{1,5})?$`)
++
++func (n *Node) prepareBlindFill(value json.RawMessage) (json.RawMessage, func(), error) {
++ return n.prepareBlindFillAt(value, time.Now())
++}
++
++func (n *Node) prepareBlindFillAt(value json.RawMessage, now time.Time) (json.RawMessage, func(), error) {
++ var req blindFillRequest
++ if json.Unmarshal(value, &req) != nil || req.Action != "blind_fill" {
++ return value, func() {}, nil
++ }
++ if n.opt.Creds == nil || !strings.HasPrefix(n.opt.Creds.Principal, "nod_") || !exactFillRequestID.MatchString(req.RequestID) || len(req.Envelope) > 16384 {
++ return nil, func() {}, fmt.Errorf("invalid blind fill request")
++ }
++ encoded, err := lockeropen.DecodeEnvelope(req.Envelope)
++ if err != nil {
++ return nil, func() {}, err
++ }
++ defer wipeSecret(encoded)
++ key, err := send.LoadOrCreate(n.opt.Paths.ConfigDir)
++ if err != nil {
++ return nil, func() {}, fmt.Errorf("Link Send key is unavailable")
++ }
++ plain, err := lockeropen.Open(encoded, lockeropen.JWK{Kty: key.Kty, Crv: key.Crv, X: key.X, Y: key.Y, D: key.D}, lockeropen.Subject{Type: "node", ID: n.opt.Creds.Principal})
++ if err != nil {
++ return nil, func() {}, err
++ }
++ defer wipeSecret(plain)
++ var grant blindFillGrant
++ var fields map[string]json.RawMessage
++ if json.Unmarshal(plain, &fields) != nil || !onlyFillFields(fields) || json.Unmarshal(plain, &grant) != nil {
++ return nil, func() {}, fmt.Errorf("invalid blind fill grant")
++ }
++ defer func() { grant.Username, grant.Password, grant.Code = "", "", "" }()
++ if !exactFillRequestID.MatchString(grant.RequestID) || grant.RequestID != req.RequestID || len(grant.Username) == 0 || len(grant.Username) > 4096 || len(grant.Password) == 0 || len(grant.Password) > 4096 || len(grant.Code) > 8 || (grant.Code != "" && !totpDigits.MatchString(grant.Code)) {
++ return nil, func() {}, fmt.Errorf("invalid blind fill grant")
++ }
++ if grant.Exp <= now.UnixMilli() || grant.Exp > now.Add(60*time.Second).UnixMilli() {
++ return nil, func() {}, fmt.Errorf("expired blind fill grant")
++ }
++ if grantOrigin, valid := exactHTTPOrigin(grant.Origin); !valid || grantOrigin != grant.Origin {
++ return nil, func() {}, fmt.Errorf("blind fill origin mismatch")
++ }
++ // Run makes a grant single-use, but Run is exactly the party that must not be able to reuse one: the machine itself
++ // refuses a request id it has already opened (kept for twice the grant's 60 s life).
++ if !claimFillRequest(req.RequestID, now) {
++ return nil, func() {}, fmt.Errorf("blind fill grant reused")
++ }
++ pluginRequest := blindFillPluginRequest{Action: "blind_fill", RequestID: req.RequestID, Origin: grant.Origin, Username: grant.Username, Password: grant.Password, Code: grant.Code, Submit: grant.Submit}
++ pluginValue, err := json.Marshal(pluginRequest)
++ pluginRequest.Username, pluginRequest.Password, pluginRequest.Code = "", "", ""
++ if err != nil {
++ return nil, func() {}, fmt.Errorf("invalid blind fill grant")
++ }
++ return pluginValue, func() { wipeSecret(pluginValue) }, nil
++}
++
++func onlyFillFields(fields map[string]json.RawMessage) bool {
++ if len(fields) < 6 || len(fields) > 7 {
++ return false
++ }
++ for _, key := range []string{"request_id", "origin", "username", "password", "submit", "exp"} {
++ if _, ok := fields[key]; !ok {
++ return false
++ }
++ }
++ for key := range fields {
++ if key != "request_id" && key != "origin" && key != "username" && key != "password" && key != "code" && key != "submit" && key != "exp" {
++ return false
++ }
++ }
++ return true
++}
++
++func exactHTTPOrigin(raw string) (string, bool) {
++ if !exactHTTPSOrigin.MatchString(raw) {
++ return "", false
++ }
++ u, err := url.Parse(raw)
++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Host != strings.ToLower(u.Host) {
++ return "", false
++ }
++ return raw, true
++}
++
++func wipeSecret(b []byte) {
++ for i := range b {
++ b[i] = 0
++ }
++}
++
++var (
++ filledMu sync.Mutex
++ filledSeen = map[string]time.Time{}
++)
++
++const filledKeep = 2 * time.Minute
++
++// claimFillRequest records a request id the first time it is opened and refuses it after that.
++func claimFillRequest(id string, now time.Time) bool {
++ filledMu.Lock()
++ defer filledMu.Unlock()
++ for k, at := range filledSeen {
++ if now.Sub(at) > filledKeep {
++ delete(filledSeen, k)
++ }
++ }
++ if _, ok := filledSeen[id]; ok {
++ return false
++ }
++ filledSeen[id] = now
++ return true
++}
ddiff --git a/internal/node/blindfill_test.go b/internal/node/blindfill_test.go
nnew file mode 100644
iindex 0000000..c1d880d
---- /dev/null
++++ b/internal/node/blindfill_test.go
@@@ -0,0 +1,100 @@
++package node
++
++import (
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "strings"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
++ "github.com/OpenVibers/OpenVibe.Node/internal/send"
++)
++
++func blindFillTestNode(t *testing.T) (*Node, string) {
++ t.Helper()
++ b, err := os.ReadFile(filepath.Join("..", "lockeropen", "testdata", "sdk-link-send-v1.json"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ var v struct {
++ Private send.JWK `json:"private_jwk"`
++ Envelope string `json:"envelope_base64"`
++ Recipient struct {
++ ID string `json:"id"`
++ } `json:"recipient"`
++ Expected map[string]any `json:"expected"`
++ }
++ if err := json.Unmarshal(b, &v); err != nil {
++ t.Fatal(err)
++ }
++ dir := t.TempDir()
++ key, _ := json.Marshal(v.Private)
++ if err := os.WriteFile(filepath.Join(dir, "link-send-key.json"), key, 0o600); err != nil {
++ t.Fatal(err)
++ }
++ n := &Node{opt: Options{Creds: &credentials.Credentials{Principal: v.Recipient.ID}, Paths: config.Paths{ConfigDir: dir}}}
++ raw, _ := json.Marshal(map[string]any{"action": "blind_fill", "request_id": v.Expected["request_id"], "envelope": v.Envelope})
++ return n, string(raw)
++}
++
++func TestPrepareBlindFillOpensSDKGrantAndClearsPayload(t *testing.T) {
++ n, raw := blindFillTestNode(t)
++ plugin, clear, err := n.prepareBlindFillAt([]byte(raw), time.UnixMilli(1893455970000))
++ if err != nil {
++ t.Fatal(err)
++ }
++ var got map[string]any
++ if err := json.Unmarshal(plugin, &got); err != nil {
++ t.Fatal(err)
++ }
++ if got["action"] != "blind_fill" || got["username"] != "vector-user" || got["password"] != "vector-password" || got["code"] != "123456" || got["submit"] != true {
++ t.Fatal("SDK grant did not open as expected")
++ }
++ clear()
++ if strings.Trim(string(plugin), "\x00") != "" {
++ t.Fatal("plaintext plugin payload was not cleared")
++ }
++}
++
++func TestPrepareBlindFillBindsRequestAndRecipient(t *testing.T) {
++ n, raw := blindFillTestNode(t)
++ for _, mutate := range []func(map[string]any){
++ func(v map[string]any) { v["request_id"] = "different" },
++ func(v map[string]any) { v["envelope"] = "not-base64" },
++ } {
++ var v map[string]any
++ _ = json.Unmarshal([]byte(raw), &v)
++ mutate(v)
++ b, _ := json.Marshal(v)
++ if _, _, err := n.prepareBlindFill(b); err == nil {
++ t.Fatal("mismatched grant was accepted")
++ }
++ }
++ if _, _, err := n.prepareBlindFillAt([]byte(raw), time.UnixMilli(1893456000001)); err == nil {
++ t.Fatal("expired grant was accepted")
++ }
++ for _, bad := range []string{"http://example.org", "https://example.org/path", "https://example.org/", "https://EXAMPLE.org"} {
++ if _, ok := exactHTTPOrigin(bad); ok {
++ t.Fatalf("invalid origin was accepted: %s", bad)
++ }
++ }
++}
++
++func TestPrepareBlindFillRefusesAReusedGrant(t *testing.T) {
++ n, raw := blindFillTestNode(t)
++ at := time.UnixMilli(1893455970000)
++ filledMu.Lock()
++ filledSeen = map[string]time.Time{}
++ filledMu.Unlock()
++ if _, clear, err := n.prepareBlindFillAt([]byte(raw), at); err != nil {
++ t.Fatal(err)
++ } else {
++ clear()
++ }
++ if _, _, err := n.prepareBlindFillAt([]byte(raw), at.Add(5*time.Second)); err == nil || !strings.Contains(err.Error(), "reused") {
++ t.Fatalf("the same grant opened twice: %v", err)
++ }
++}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 816b516..1fcbcf2 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -851,7 +851,18 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
n.inputs[c.ID] = true
n.mu.Unlock()
}
-- wait := p.BeginAs(n.ctx, c.ID, c.Kind, c.Value, int(window), c.Operator)
++ commandValue := c.Value
++ clearCommandValue := func() {}
++ if c.Kind == protocol.KindBrowser {
++ var err error
++ commandValue, clearCommandValue, err = n.prepareBlindFill(c.Value)
++ if err != nil {
++ nack(protocol.FaultBadValue, "the blind sign-in grant is invalid or expired")
++ return
++ }
++ }
++ wait := p.BeginAs(n.ctx, c.ID, c.Kind, commandValue, int(window), c.Operator)
++ clearCommandValue()
go func() {
r := wait()
if c.Kind == protocol.KindInput {
ddiff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
iindex 1036b16..e294f16 100644
---- a/internal/protocol/protocol.go
++++ b/internal/protocol/protocol.go
@@@ -234,7 +234,7 @@ func ComputerActs(kind string, value json.RawMessage) bool {
Action string `json:"action"`
}
_ = json.Unmarshal(value, &v)
-- return v.Action != "read" && v.Action != "screenshot" && v.Action != "close"
++ return v.Action != "read" && v.Action != "screenshot" && v.Action != "close" && v.Action != "active_origin"
case KindDesk:
var v struct {
Action string `json:"action"`
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex f394cd9..76f98d0 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -169,8 +169,8 @@ STREAM_WIDTH = 1600
STREAM_QUALITY = 70
STREAM_MAX_FAILURES = 5
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
--BROWSER_READS = frozenset({"read", "screenshot", "close"})
--BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
++BROWSER_READS = frozenset({"read", "screenshot", "close", "active_origin"})
++BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back", "blind_fill"})
MAX_URL = 2048
MAX_DESK_PROGRAMS = 16
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
@@@ -1007,6 +1007,12 @@ class Computer(Plugin):
return self._act(cmd, level, self._driving(run), summary, threaded=True)
if cmd.kind == "browser":
run, summary, acting = self._browser_op(v, level, env=desk_env, desk=desk_name)
++ if v.get("action") == "blind_fill":
++ if RANK[level] < RANK["ask"]:
++ raise Fault("not_allowed", "this machine allows observing only")
++ # The Node has opened and checked the single-use encrypted Companion grant. At level ask this is the
++ # owner's approval; observe-only and the Node's emergency stop still refuse it.
++ return self._act(cmd, "trusted", run, summary, threaded=True)
if acting:
return self._act(cmd, level, run, summary, threaded=True)
return self._start(cmd, run)
@@@ -2000,6 +2006,13 @@ class Computer(Plugin):
raise Fault("bad_value", "fill takes the selector of the field")
if not isinstance(text, str) or len(text) > MAX_TYPE or "\x00" in text:
raise Fault("bad_value", "text is at most %d characters" % MAX_TYPE)
++ if action == "blind_fill":
++ if not isinstance(v.get("request_id"), str) or not v.get("request_id") or len(v["request_id"]) > 100:
++ raise Fault("bad_value", "blind fill request is invalid")
++ if not isinstance(v.get("origin"), str) or not isinstance(v.get("username"), str) or not isinstance(v.get("password"), str):
++ raise Fault("bad_value", "blind fill grant is invalid")
++ if len(v["username"]) > 4096 or len(v["password"]) > 4096 or not isinstance(v.get("code", ""), str) or len(v.get("code", "")) > 32 or not isinstance(v.get("submit"), bool):
++ raise Fault("bad_value", "blind fill grant is invalid")
if action == "press" and key not in KEY_CODES:
raise Fault("bad_value", "key is one of %s" % ", ".join(sorted(KEY_CODES)))
width = _int(v.get("max_width"), "max_width", 320, 2560, 1280)
@@@ -2037,6 +2050,18 @@ class Computer(Plugin):
out = b.open(url)
elif action == "read":
out = b.read()
++ elif action == "active_origin":
++ out = b.active_origin()
++ elif action == "blind_fill":
++ try:
++ out = b.blind_fill(v["request_id"], v["origin"], v["username"], v["password"], v.get("code", ""), v["submit"])
++ finally:
++ # Python strings are immutable; overwrite the request slots and drop local references promptly.
++ for secret in ("username", "password", "code"):
++ value = v.get(secret)
++ if isinstance(value, str):
++ v[secret] = "\x00" * len(value)
++ del value
elif action == "screenshot":
out = b.screenshot(width)
elif action == "click":
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex bfa9324..4b3da7f 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -18,6 +18,7 @@ Every page answer is ``{url, title, text, links}``: the page's visible text (cut
import base64
import json
import os
++import re
import subprocess
import sys
import threading
@@@ -30,6 +31,9 @@ MAX_ELEMENTS = 120
MAX_APPEARED = 8
MAX_FOLLOWED = 5
MAX_SESSION_COOKIES = 3000
++EXACT_HTTPS_ORIGIN = re.compile(r"^https://[a-z0-9.-]+(:[0-9]{1,5})?$")
++_blind_fill_lock = threading.Lock()
++_blind_fill_ids = {}
# What Storage.setCookies takes back of what Storage.getCookies gives.
COOKIE_FIELDS = ("name", "value", "domain", "path", "secure", "httpOnly", "sameSite", "priority", "sourceScheme",
"sourcePort", "partitionKey") # pages opened from pages (a sign-in popup, a new tab) followed at most this deep
@@@ -452,21 +456,27 @@ class Browser:
time.sleep(0.05)
return False
-- def _eval(self, expression, gesture=False):
++ def _eval(self, expression, gesture=False, context_id=None, sanitize_errors=False):
# gesture: as a person's click (user activation), so a page may open its sign-in popup from the agent's click.
params = {"expression": expression, "returnByValue": True, "awaitPromise": True}
if gesture:
params["userGesture"] = True
++ if context_id is not None:
++ params["contextId"] = context_id
session = self.session
try:
r = self.call("Runtime.evaluate", params)
except BrowserError as e:
++ if sanitize_errors:
++ raise BrowserError("bad_value", "the browser refused the page script")
# The page in front just closed (a popup closes itself after a sign-in) before the browser said so: wait for
# it to say so (a second at most), then once more on the page in front again.
if "session" not in e.message.lower() or not self._front_changed(session):
raise
r = self.call("Runtime.evaluate", params)
if r.get("exceptionDetails"):
++ if sanitize_errors:
++ raise BrowserError("bad_value", "the browser refused the page script")
raise BrowserError("bad_value", "the page refused: %s" % r["exceptionDetails"].get("text", "an exception"))
return r.get("result", {}).get("value")
@@@ -496,6 +506,80 @@ class Browser:
self._need()
return self.summary()
++ def active_origin(self):
++ """Return the active target's top-level frame origin, never child-frame URLs."""
++ self._need()
++ self._follow()
++ tree = self.call("Page.getFrameTree").get("frameTree", {}).get("frame", {})
++ raw = tree.get("url", "")
++ from urllib.parse import urlsplit
++ u = urlsplit(raw)
++ if u.scheme != "https" or not u.hostname:
++ return {"origin": None}
++ host = u.hostname.lower()
++ if ":" in host:
++ host = "[" + host + "]"
++ try:
++ port = u.port
++ except ValueError:
++ return {"origin": None}
++ origin = "%s://%s%s" % (u.scheme, host, (":" + str(port)) if port and port != 443 else "")
++ return {"origin": origin if EXACT_HTTPS_ORIGIN.fullmatch(origin) else None}
++
++ def blind_fill(self, request_id, origin, username, password, code="", submit=False):
++ """Fill visible fields in the top-level document and return only an outcome code."""
++ self._need()
++ import json
++ if not isinstance(request_id, str) or not re.fullmatch(r"fr_[A-Za-z0-9_-]{16,64}", request_id):
++ raise BrowserError("bad_value", "invalid fill request")
++ if not isinstance(origin, str) or not EXACT_HTTPS_ORIGIN.fullmatch(origin):
++ raise BrowserError("bad_value", "invalid fill origin")
++ if not isinstance(username, str) or not username or len(username) > 4096 or not isinstance(password, str) or not password or len(password) > 4096:
++ raise BrowserError("bad_value", "invalid fill grant")
++ if not isinstance(code, str) or len(code) > 32 or not isinstance(submit, bool):
++ raise BrowserError("bad_value", "invalid fill grant")
++ now = time.monotonic()
++ with _blind_fill_lock:
++ for key, expiry in list(_blind_fill_ids.items()):
++ if expiry <= now: del _blind_fill_ids[key]
++ if request_id in _blind_fill_ids:
++ return {"outcome": "failed", "reason": "reused"}
++ _blind_fill_ids[request_id] = now + 120
++ active = self.active_origin().get("origin")
++ if active != origin:
++ return {"outcome": "failed", "reason": "origin_changed"}
++ script = """(() => {
++ const origin = %s;
++ if (location.origin !== origin) return {ok:false,reason:'origin_changed'};
++ const visible = e => { const r=e.getBoundingClientRect(),s=getComputedStyle(e); return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'&&!e.disabled; };
++ const inputs = [...document.querySelectorAll('input')].filter(visible);
++ const password = inputs.find(e => e.type === 'password');
++ const username = inputs.find(e => e !== password && (e.autocomplete === 'username' || ['email','text'].includes(e.type)) && !/search|hidden/i.test(e.name+' '+e.autocomplete));
++ if (!username || !password || username.ownerDocument !== document || password.ownerDocument !== document) return {ok:false,reason:'no_fields'};
++ const set = (e,v) => { const p=Object.getPrototypeOf(e),d=Object.getOwnPropertyDescriptor(p,'value'); if(d&&d.set)d.set.call(e,v); else e.value=v; e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true})); };
++ set(username,%s); set(password,%s);
++ const code = %s;
++ if(code) { const otp=inputs.find(e => e !== username && e !== password && (e.autocomplete === 'one-time-code' || /otp|one.?time|verification.?code/i.test(e.name+' '+e.id+' '+e.placeholder))); if(!otp) return {ok:false,reason:'no_fields'}; set(otp,code); }
++ if(%s) { const form=password.form; if(!form) return {ok:false,reason:'refused'}; const button=[...form.querySelectorAll('button[type=submit],input[type=submit]')].find(visible); form.requestSubmit(button || undefined); }
++ return {ok:true};
++ })()""" % (json.dumps(origin), json.dumps(username), json.dumps(password), json.dumps(code), "true" if submit else "false")
++ frame = self.call("Page.getFrameTree").get("frameTree", {}).get("frame", {})
++ frame_id = frame.get("id")
++ if not frame_id:
++ return {"outcome": "failed", "reason": "origin_changed"}
++ world = self.call("Page.createIsolatedWorld", {"frameId": frame_id, "grantUniveralAccess": False})
++ context_id = world.get("executionContextId")
++ if not context_id:
++ return {"outcome": "failed", "reason": "refused"}
++ try:
++ result = self._eval(script, context_id=context_id, sanitize_errors=True)
++ finally:
++ # Python strings cannot be zeroed; release the grant and generated script references promptly.
++ del script, username, password, code
++ if not isinstance(result, dict) or result.get("ok") is not True:
++ return {"outcome": "failed", "reason": result.get("reason", "refused") if isinstance(result, dict) else "refused"}
++ return {"outcome": "ok"}
++
def _before(self):
try:
return self._eval(BEFORE_JS)
ddiff --git a/plugins/computer/tests/test_browser.py b/plugins/computer/tests/test_browser.py
iindex 2ff95c1..7903c16 100644
---- a/plugins/computer/tests/test_browser.py
++++ b/plugins/computer/tests/test_browser.py
@@@ -59,6 +59,14 @@ class FakeBrowser:
self.calls.append(("back",))
return self._page()
++ def active_origin(self):
++ self.calls.append(("active_origin",))
++ return {"origin": "https://example.org"}
++
++ def blind_fill(self, request_id, origin, username, password, code="", submit=False):
++ self.calls.append(("blind_fill", request_id, origin, username, password, code, submit))
++ return {"outcome": "ok"}
++
def screenshot(self, max_width):
self.calls.append(("screenshot", max_width))
return {"format": "jpeg", "width": max_width, "height": 10, "content_b64": ""}
@@@ -179,6 +187,54 @@ class BrowserKindTest(unittest.TestCase):
reply(self.out, self.cmd({"action": "open", "url": "https://example.org/"}))
self.assertIs(self.made[0].allow_downloads, True)
++ def test_blind_fill_is_companion_approved_and_never_returns_fields(self):
++ self.boot("ask")
++ request = {"action":"blind_fill","request_id":"fr_01JAB2C3D4E5F6G7H8J9K0MNPQ","origin":"https://example.org","username":"alice","password":"vector-password","code":"123456","submit":True}
++ result = reply(self.out, self.cmd(request))
++ self.assertEqual(result.get("data"), {"outcome":"ok"})
++ self.assertEqual(self.made[0].calls[-1], ("blind_fill","fr_01JAB2C3D4E5F6G7H8J9K0MNPQ","https://example.org","alice","vector-password","123456",True))
++ self.assertEqual((request["username"],request["password"],request["code"]),("\x00"*5,"\x00"*15,"\x00"*6))
++ self.assertNotIn("vector-password", json.dumps(self.out.msgs()))
++
++ def test_active_origin_is_read_only_and_top_level(self):
++ self.boot("observe")
++ result = reply(self.out, self.cmd({"action":"active_origin"}))
++ self.assertEqual(result.get("data"), {"origin":"https://example.org"})
++ self.assertEqual(self.made[0].calls[-1], ("active_origin",))
++
++
++class BlindFillCDPTest(unittest.TestCase):
++ def test_top_frame_only_and_origin_recheck(self):
++ b=Browser.__new__(Browser); b._need=lambda:None; b._follow=lambda:None
++ b.call=lambda method, params=None: {"frameTree":{"frame":{"url":"https://example.org/login","id":"top"}},"executionContextId":7}
++ seen=[]; b._eval=lambda script, **kwargs: seen.append((script,kwargs)) or {"ok":False,"reason":"no_fields"}
++ self.assertEqual(b.active_origin(),{"origin":"https://example.org"})
++ request_id = "fr_01JAB2C3D4E5F6G7H8J9K0MNPA"
++ self.assertEqual(b.blind_fill(request_id,"https://example.org","u","p"),{"outcome":"failed","reason":"no_fields"})
++ self.assertIn("location.origin !== origin",seen[0][0])
++ self.assertIn("document.querySelectorAll('input')",seen[0][0])
++ self.assertNotIn("iframe",seen[0][0].lower(),"child frames are never searched")
++ self.assertIn("if(false)",seen[0][0],"submit remains guarded by the user's choice")
++ self.assertEqual(seen[0][1], {"context_id":7,"sanitize_errors":True})
++ self.assertEqual(b.blind_fill(request_id,"https://example.org","u","p"),{"outcome":"failed","reason":"reused"})
++ b.call=lambda method, params=None: {"frameTree":{"frame":{"url":"http://example.org/login","id":"top"}},"executionContextId":7}
++ self.assertEqual(b.active_origin(),{"origin":None},"plain HTTP cannot receive credentials")
++
++ def test_blind_fill_evaluation_errors_do_not_echo_script_or_secret(self):
++ b=Browser.__new__(Browser)
++ b.session="s"
++ b.call=lambda method, params=None: {"exceptionDetails":{"text":"vector-password leaked from expression"}}
++ with self.assertRaises(BrowserError) as caught:
++ b._eval("secret vector-password", context_id=9, sanitize_errors=True)
++ self.assertNotIn("vector-password", str(caught.exception))
++ self.assertNotIn("secret", str(caught.exception))
++
++ def test_blind_fill_refuses_when_the_active_top_origin_changed(self):
++ b=Browser.__new__(Browser); b._need=lambda:None; b._follow=lambda:None
++ b.call=lambda method, params=None: {"frameTree":{"frame":{"url":"https://other.example/login","id":"top"}},"executionContextId":7}
++ b._eval=lambda script: self.fail("changed origin must be refused before filling")
++ self.assertEqual(b.blind_fill("fr_01JAB2C3D4E5F6G7H8J9K0MNPQ","https://example.org","u","p"),{"outcome":"failed","reason":"origin_changed"})
++
class DownloadPolicyTest(unittest.TestCase):
def test_the_download_behavior_sent_to_the_browser(self):