openvibers/openvibe.link Commits
Add Node blind fill handling (#142)
Changes
nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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)++}nnew file mode 100644@@@ -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++ }++}nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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)++ }++}@@@ -851,7 +851,18 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, rn.inputs[c.ID] = truen.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 {@@@ -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"`@@@ -169,8 +169,8 @@ STREAM_WIDTH = 1600STREAM_QUALITY = 70STREAM_MAX_FAILURES = 5INPUT_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 = 2048MAX_DESK_PROGRAMS = 16PROFILE_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 valueelif action == "screenshot":out = b.screenshot(width)elif action == "click":@@@ -18,6 +18,7 @@ Every page answer is ``{url, title, text, links}``: the page's visible text (cutimport base64import jsonimport os++import reimport subprocessimport sysimport threading@@@ -30,6 +31,9 @@ MAX_ELEMENTS = 120MAX_APPEARED = 8MAX_FOLLOWED = 5MAX_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_idsession = self.sessiontry: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):raiser = 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)@@@ -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):
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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)
+ }
+}
@@ -851,7 +851,18 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
@@ -851,7 +851,18 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
n.inputs[c.ID] = true
n.inputs[c.ID] = true
n.mu.Unlock()
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() {
go func() {
r := wait()
r := wait()
if c.Kind == protocol.KindInput {
if c.Kind == protocol.KindInput {
@@ -234,7 +234,7 @@ func ComputerActs(kind string, value json.RawMessage) bool {
@@ -234,7 +234,7 @@ func ComputerActs(kind string, value json.RawMessage) bool {
Action string `json:"action"`
Action string `json:"action"`
}
}
_ = json.Unmarshal(value, &v)
_ = 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:
case KindDesk:
var v struct {
var v struct {
Action string `json:"action"`
Action string `json:"action"`
@@ -169,8 +169,8 @@ STREAM_WIDTH = 1600
@@ -169,8 +169,8 @@ STREAM_WIDTH = 1600
STREAM_QUALITY = 70
STREAM_QUALITY = 70
STREAM_MAX_FAILURES = 5
STREAM_MAX_FAILURES = 5
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
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_URL = 2048
MAX_DESK_PROGRAMS = 16
MAX_DESK_PROGRAMS = 16
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
@@ -1007,6 +1007,12 @@ class Computer(Plugin):
@@ -1007,6 +1007,12 @@ class Computer(Plugin):
return self._act(cmd, level, self._driving(run), summary, threaded=True)
return self._act(cmd, level, self._driving(run), summary, threaded=True)
if cmd.kind == "browser":
if cmd.kind == "browser":
run, summary, acting = self._browser_op(v, level, env=desk_env, desk=desk_name)
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:
if acting:
return self._act(cmd, level, run, summary, threaded=True)
return self._act(cmd, level, run, summary, threaded=True)
return self._start(cmd, run)
return self._start(cmd, run)
@@ -2000,6 +2006,13 @@ class Computer(Plugin):
@@ -2000,6 +2006,13 @@ class Computer(Plugin):
raise Fault("bad_value", "fill takes the selector of the field")
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:
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)
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:
if action == "press" and key not in KEY_CODES:
raise Fault("bad_value", "key is one of %s" % ", ".join(sorted(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)
width = _int(v.get("max_width"), "max_width", 320, 2560, 1280)
@@ -2037,6 +2050,18 @@ class Computer(Plugin):
@@ -2037,6 +2050,18 @@ class Computer(Plugin):
out = b.open(url)
out = b.open(url)
elif action == "read":
elif action == "read":
out = b.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":
elif action == "screenshot":
out = b.screenshot(width)
out = b.screenshot(width)
elif action == "click":
elif action == "click":
@@ -18,6 +18,7 @@ Every page answer is ``{url, title, text, links}``: the page's visible text (cut
@@ -18,6 +18,7 @@ Every page answer is ``{url, title, text, links}``: the page's visible text (cut
import base64
import base64
import json
import json
import os
import os
+import re
import subprocess
import subprocess
import sys
import sys
import threading
import threading
@@ -30,6 +31,9 @@ MAX_ELEMENTS = 120
@@ -30,6 +31,9 @@ MAX_ELEMENTS = 120
MAX_APPEARED = 8
MAX_APPEARED = 8
MAX_FOLLOWED = 5
MAX_FOLLOWED = 5
MAX_SESSION_COOKIES = 3000
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.
# What Storage.setCookies takes back of what Storage.getCookies gives.
COOKIE_FIELDS = ("name", "value", "domain", "path", "secure", "httpOnly", "sameSite", "priority", "sourceScheme",
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
"sourcePort", "partitionKey") # pages opened from pages (a sign-in popup, a new tab) followed at most this deep
@@ -452,21 +456,27 @@ class Browser:
@@ -452,21 +456,27 @@ class Browser:
time.sleep(0.05)
time.sleep(0.05)
return False
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.
# 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}
params = {"expression": expression, "returnByValue": True, "awaitPromise": True}
if gesture:
if gesture:
params["userGesture"] = True
params["userGesture"] = True
+ if context_id is not None:
+ params["contextId"] = context_id
session = self.session
session = self.session
try:
try:
r = self.call("Runtime.evaluate", params)
r = self.call("Runtime.evaluate", params)
except BrowserError as e:
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
# 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.
# 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):
if "session" not in e.message.lower() or not self._front_changed(session):
raise
raise
r = self.call("Runtime.evaluate", params)
r = self.call("Runtime.evaluate", params)
if r.get("exceptionDetails"):
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"))
raise BrowserError("bad_value", "the page refused: %s" % r["exceptionDetails"].get("text", "an exception"))
return r.get("result", {}).get("value")
return r.get("result", {}).get("value")
@@ -496,6 +506,80 @@ class Browser:
@@ -496,6 +506,80 @@ class Browser:
self._need()
self._need()
return self.summary()
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):
def _before(self):
try:
try:
return self._eval(BEFORE_JS)
return self._eval(BEFORE_JS)
@@ -59,6 +59,14 @@ class FakeBrowser:
@@ -59,6 +59,14 @@ class FakeBrowser:
self.calls.append(("back",))
self.calls.append(("back",))
return self._page()
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):
def screenshot(self, max_width):
self.calls.append(("screenshot", max_width))
self.calls.append(("screenshot", max_width))
return {"format": "jpeg", "width": max_width, "height": 10, "content_b64": ""}
return {"format": "jpeg", "width": max_width, "height": 10, "content_b64": ""}
@@ -179,6 +187,54 @@ class BrowserKindTest(unittest.TestCase):
@@ -179,6 +187,54 @@ class BrowserKindTest(unittest.TestCase):
reply(self.out, self.cmd({"action": "open", "url": "https://example.org/"}))
reply(self.out, self.cmd({"action": "open", "url": "https://example.org/"}))
self.assertIs(self.made[0].allow_downloads, True)
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):
class DownloadPolicyTest(unittest.TestCase):
def test_the_download_behavior_sent_to_the_browser(self):
def test_the_download_behavior_sent_to_the_browser(self):